From 731315144e7ebd84ce1a2a97410f60893fb0887e Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 29 Jun 2025 21:59:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=90=AD=E5=BB=BA-?= =?UTF-8?q?=E6=9C=AA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + Dockerfile | 93 +- Makefile | 11 +- cmd/gpt-load/embed.go | 6 + cmd/gpt-load/main.go | 125 +- docker-compose.yml | 78 +- go.mod | 8 +- go.sum | 16 +- internal/channel/channel.go | 14 + internal/channel/factory.go | 18 + internal/channel/gemini_channel.go | 55 + internal/channel/openai_channel.go | 55 + internal/config/manager.go | 21 +- internal/db/database.go | 62 + internal/handler/dashboard_handler.go | 62 + internal/handler/group_handler.go | 125 ++ internal/handler/handler.go | 157 +- internal/handler/key_handler.go | 119 ++ internal/handler/log_handler.go | 79 + internal/handler/reload_handler.go | 26 + internal/handler/settings_handler.go | 62 + internal/models/types.go | 66 + internal/proxy/server.go | 520 +----- internal/response/response.go | 48 + internal/types/types.go | 1 + web/.gitignore | 24 + web/README.md | 5 + web/index.html | 13 + web/package-lock.json | 2159 ++++++++++++++++++++++++ web/package.json | 27 + web/src/App.vue | 7 + web/src/api/dashboard.ts | 6 + web/src/api/groups.ts | 42 + web/src/api/index.ts | 14 + web/src/api/keys.ts | 36 + web/src/api/logs.ts | 24 + web/src/api/settings.ts | 10 + web/src/assets/vue.svg | 1 + web/src/components/GroupConfigForm.vue | 80 + web/src/components/GroupList.vue | 61 + web/src/components/KeyTable.vue | 219 +++ web/src/components/LogFilter.vue | 66 + web/src/components/StatsChart.vue | 52 + web/src/layouts/MainLayout.vue | 54 + web/src/main.ts | 14 + web/src/router/index.ts | 39 + web/src/stores/dashboardStore.ts | 27 + web/src/stores/groupStore.ts | 52 + web/src/stores/keyStore.ts | 46 + web/src/stores/logStore.ts | 61 + web/src/stores/settingStore.ts | 47 + web/src/style.css | 79 + web/src/types/models.ts | 59 + web/src/views/Dashboard.vue | 53 + web/src/views/Groups.vue | 53 + web/src/views/Logs.vue | 63 + web/src/views/Settings.vue | 44 + web/src/vite-env.d.ts | 1 + web/tsconfig.app.json | 19 + web/tsconfig.json | 7 + web/tsconfig.node.json | 25 + web/vite.config.ts | 17 + 62 files changed, 4831 insertions(+), 604 deletions(-) create mode 100644 cmd/gpt-load/embed.go create mode 100644 internal/channel/channel.go create mode 100644 internal/channel/factory.go create mode 100644 internal/channel/gemini_channel.go create mode 100644 internal/channel/openai_channel.go create mode 100644 internal/db/database.go create mode 100644 internal/handler/dashboard_handler.go create mode 100644 internal/handler/group_handler.go create mode 100644 internal/handler/key_handler.go create mode 100644 internal/handler/log_handler.go create mode 100644 internal/handler/reload_handler.go create mode 100644 internal/handler/settings_handler.go create mode 100644 internal/models/types.go create mode 100644 internal/response/response.go create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.vue create mode 100644 web/src/api/dashboard.ts create mode 100644 web/src/api/groups.ts create mode 100644 web/src/api/index.ts create mode 100644 web/src/api/keys.ts create mode 100644 web/src/api/logs.ts create mode 100644 web/src/api/settings.ts create mode 100644 web/src/assets/vue.svg create mode 100644 web/src/components/GroupConfigForm.vue create mode 100644 web/src/components/GroupList.vue create mode 100644 web/src/components/KeyTable.vue create mode 100644 web/src/components/LogFilter.vue create mode 100644 web/src/components/StatsChart.vue create mode 100644 web/src/layouts/MainLayout.vue create mode 100644 web/src/main.ts create mode 100644 web/src/router/index.ts create mode 100644 web/src/stores/dashboardStore.ts create mode 100644 web/src/stores/groupStore.ts create mode 100644 web/src/stores/keyStore.ts create mode 100644 web/src/stores/logStore.ts create mode 100644 web/src/stores/settingStore.ts create mode 100644 web/src/style.css create mode 100644 web/src/types/models.ts create mode 100644 web/src/views/Dashboard.vue create mode 100644 web/src/views/Groups.vue create mode 100644 web/src/views/Logs.vue create mode 100644 web/src/views/Settings.vue create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore index 9f2eb7b..5468b82 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,5 @@ charts/*.tgz run-local.sh start-local.sh dev-setup.sh + +cmd/gpt-load/dist diff --git a/Dockerfile b/Dockerfile index 0d4d9c4..ac2132e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,62 +1,81 @@ -# 多阶段构建 Dockerfile for OpenAI 多密钥代理服务器 (Go版本) +# --- Stage 1: Frontend Builder --- +FROM node:20-alpine AS frontend-builder -# 构建阶段 -FROM golang:1.21-alpine AS builder +WORKDIR /app/web + +# Copy web project files +COPY web/package.json web/package-lock.json ./ +COPY web/tsconfig.json web/tsconfig.node.json web/tsconfig.app.json ./ +COPY web/vite.config.ts ./ + +# Install dependencies +RUN npm install + +# Copy the rest of the web source code +COPY web/ ./ + +# Build the frontend application +RUN npm run build + +# --- Stage 2: Backend Builder --- +FROM golang:1.22-alpine AS backend-builder -# 设置工作目录 WORKDIR /app -# 安装必要的包 -RUN apk add --no-cache git ca-certificates tzdata +# Install build tools +RUN apk add --no-cache git build-base -# 复制 go mod 文件 +# Copy Go module files and download dependencies COPY go.mod go.sum ./ - -# 下载依赖 RUN go mod download -# 复制源代码 +# Copy the entire Go project source code COPY . . -# 构建应用 - 支持多平台 -ARG TARGETOS -ARG TARGETARCH -RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ - -ldflags="-w -s -X main.Version=2.0.0" \ - -o gpt-load \ - ./cmd/gpt-load/main.go +# Copy the built frontend from the previous stage +COPY --from=frontend-builder /app/web/dist ./web/dist -# 运行阶段 +# Build the Go application +# We use CGO_ENABLED=0 to create a static binary +# -ldflags="-w -s" strips debug information and symbols to reduce binary size +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-w -s" \ + -o /gpt-load \ + ./cmd/gpt-load + +# --- Stage 3: Final Image --- FROM alpine:latest -# 安装必要的包 -RUN apk --no-cache add ca-certificates curl +# Install necessary runtime dependencies +# ca-certificates for HTTPS connections +# tzdata for time zone information +RUN apk --no-cache add ca-certificates tzdata -# 创建非 root 用户 -RUN addgroup -g 1001 -S appgroup && \ - adduser -S appuser -u 1001 -G appgroup +# Create a non-root user and group for security +RUN addgroup -S appgroup && adduser -S appuser -G appgroup -# 设置工作目录 +# Set the working directory WORKDIR /app -# 从构建阶段复制二进制文件 -COPY --from=builder /app/gpt-load . +# Copy the compiled binary from the backend-builder stage +COPY --from=backend-builder /gpt-load . -# 复制配置文件模板 -COPY --from=builder /app/.env.example . +# Copy the configuration file example +COPY .env.example . -# 设置权限 +# Set ownership of the app directory to the non-root user RUN chown -R appuser:appgroup /app -# 切换到非 root 用户 +# Switch to the non-root user USER appuser -# 暴露端口 -EXPOSE 3000 +# Expose the application port +# This should match the port defined in the configuration +EXPOSE 8080 -# 健康检查 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3000/health || exit 1 +# Healthcheck to ensure the application is running +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD [ "wget", "-q", "--spider", "http://localhost:8080/health" ] || exit 1 -# 启动命令 -CMD ["./gpt-load"] +# Set the entrypoint for the container +ENTRYPOINT ["/app/gpt-load"] diff --git a/Makefile b/Makefile index 8e7f8cf..390301d 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,16 @@ build-all: clean # 运行 .PHONY: run +.PHONY: run + run: - @echo "🚀 启动服务器..." - go run $(MAIN_PATH) + @echo "--- Building frontend... ---" + cd web && npm install && npm run build + @echo "--- Preparing backend... ---" + @rm -rf cmd/gpt-load/dist + @cp -r web/dist cmd/gpt-load/dist + @echo "--- Starting backend... ---" + cd $(MAIN_PATH) && go run . # 开发模式运行 .PHONY: dev diff --git a/cmd/gpt-load/embed.go b/cmd/gpt-load/embed.go new file mode 100644 index 0000000..241b8cf --- /dev/null +++ b/cmd/gpt-load/embed.go @@ -0,0 +1,6 @@ +package main + +import "embed" + +//go:embed all:dist +var WebUI embed.FS \ No newline at end of file diff --git a/cmd/gpt-load/main.go b/cmd/gpt-load/main.go index 1ce45b0..96f23c2 100644 --- a/cmd/gpt-load/main.go +++ b/cmd/gpt-load/main.go @@ -5,22 +5,27 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "os" "os/signal" + "path" "path/filepath" + "strings" "syscall" "time" "gpt-load/internal/config" + "gpt-load/internal/db" "gpt-load/internal/handler" - "gpt-load/internal/keymanager" "gpt-load/internal/middleware" + "gpt-load/internal/models" "gpt-load/internal/proxy" "gpt-load/internal/types" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) func main() { @@ -33,28 +38,33 @@ func main() { // Setup logger setupLogger(configManager) + // Initialize database + database, err := db.InitDB() + if err != nil { + logrus.Fatalf("Failed to initialize database: %v", err) + } + // Display startup information displayStartupInfo(configManager) - // Create key manager - keyManager, err := keymanager.NewManager(configManager.GetKeysConfig()) - if err != nil { - logrus.Fatalf("Failed to create key manager: %v", err) - } - defer keyManager.Close() + + // --- Asynchronous Request Logging Setup --- + requestLogChan := make(chan models.RequestLog, 1000) + go startRequestLogger(database, requestLogChan) + // --- // Create proxy server - proxyServer, err := proxy.NewProxyServer(keyManager, configManager) + proxyServer, err := proxy.NewProxyServer(database, requestLogChan) if err != nil { logrus.Fatalf("Failed to create proxy server: %v", err) } defer proxyServer.Close() // Create handlers - handlers := handler.NewHandler(keyManager, configManager) + serverHandler := handler.NewServer(database, configManager) // Setup routes - router := setupRoutes(handlers, proxyServer, configManager) + router := setupRoutes(serverHandler, proxyServer, configManager) // Create HTTP server with optimized timeout configuration serverConfig := configManager.GetServerConfig() @@ -73,8 +83,6 @@ func main() { logrus.Infof("Server address: http://%s:%d", serverConfig.Host, serverConfig.Port) logrus.Infof("Statistics: http://%s:%d/stats", serverConfig.Host, serverConfig.Port) logrus.Infof("Health check: http://%s:%d/health", serverConfig.Host, serverConfig.Port) - logrus.Infof("Reset keys: http://%s:%d/reset-keys", serverConfig.Host, serverConfig.Port) - logrus.Infof("Blacklist query: http://%s:%d/blacklist", serverConfig.Host, serverConfig.Port) logrus.Info("") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -101,7 +109,7 @@ func main() { } // setupRoutes configures the HTTP routes -func setupRoutes(handlers *handler.Handler, proxyServer *proxy.ProxyServer, configManager types.ConfigManager) *gin.Engine { +func setupRoutes(serverHandler *handler.Server, proxyServer *proxy.ProxyServer, configManager types.ConfigManager) *gin.Engine { // Set Gin mode gin.SetMode(gin.ReleaseMode) @@ -127,21 +135,57 @@ func setupRoutes(handlers *handler.Handler, proxyServer *proxy.ProxyServer, conf } // Management endpoints - router.GET("/health", handlers.Health) - router.GET("/stats", handlers.Stats) - router.GET("/blacklist", handlers.Blacklist) - router.GET("/reset-keys", handlers.ResetKeys) - router.GET("/config", handlers.GetConfig) // Debug endpoint + router.GET("/health", serverHandler.Health) + router.GET("/stats", serverHandler.Stats) + router.GET("/config", serverHandler.GetConfig) // Debug endpoint + + // Register API routes for group and key management + api := router.Group("/api") + serverHandler.RegisterAPIRoutes(api) + + // Register the main proxy route + proxy := router.Group("/proxy") + proxyServer.RegisterProxyRoutes(proxy) // Handle 405 Method Not Allowed - router.NoMethod(handlers.MethodNotAllowed) + router.NoMethod(serverHandler.MethodNotAllowed) - // Proxy all other requests (this handles 404 as well) - router.NoRoute(proxyServer.HandleProxy) + // Serve the frontend UI for all other requests + router.NoRoute(ServeUI()) return router } +// ServeUI returns a gin.HandlerFunc to serve the embedded frontend UI. +func ServeUI() gin.HandlerFunc { + subFS, err := fs.Sub(WebUI, "dist") + if err != nil { + // This should not happen at runtime if embed is correct. + // Panic is acceptable here as it's a startup failure. + panic(fmt.Sprintf("Failed to create sub filesystem for UI: %v", err)) + } + fileServer := http.FileServer(http.FS(subFS)) + + return func(c *gin.Context) { + // Clean the path to prevent directory traversal attacks. + upath := path.Clean(c.Request.URL.Path) + if !strings.HasPrefix(upath, "/") { + upath = "/" + upath + } + + // Check if the file exists in the embedded filesystem. + _, err := subFS.Open(strings.TrimPrefix(upath, "/")) + if os.IsNotExist(err) { + // The file does not exist, so we serve index.html for SPA routing. + // This allows the Vue router to handle the path. + c.Request.URL.Path = "/" + } + + // Let the http.FileServer handle the request. + fileServer.ServeHTTP(c.Writer, c.Request) + } +} + // setupLogger configures the logging system func setupLogger(configManager types.ConfigManager) { logConfig := configManager.GetLogConfig() @@ -232,3 +276,42 @@ func displayStartupInfo(configManager types.ConfigManager) { } logrus.Infof(" Request logging: %s", requestLogStatus) } + +// startRequestLogger runs a background goroutine to batch-insert request logs. +func startRequestLogger(db *gorm.DB, logChan <-chan models.RequestLog) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + logBuffer := make([]models.RequestLog, 0, 100) + + for { + select { + case logEntry, ok := <-logChan: + if !ok { + // Channel closed, flush remaining logs and exit + if len(logBuffer) > 0 { + if err := db.Create(&logBuffer).Error; err != nil { + logrus.Errorf("Failed to write remaining request logs: %v", err) + } + } + logrus.Info("Request logger stopped.") + return + } + logBuffer = append(logBuffer, logEntry) + if len(logBuffer) >= 100 { + if err := db.Create(&logBuffer).Error; err != nil { + logrus.Errorf("Failed to write request logs: %v", err) + } + logBuffer = make([]models.RequestLog, 0, 100) // Reset buffer + } + case <-ticker.C: + // Flush logs periodically + if len(logBuffer) > 0 { + if err := db.Create(&logBuffer).Error; err != nil { + logrus.Errorf("Failed to write request logs on tick: %v", err) + } + logBuffer = make([]models.RequestLog, 0, 100) // Reset buffer + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 6bba438..aae33cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,70 @@ +version: '3.8' + services: - gpt-load: - image: ghcr.io/tbphp/gpt-load:latest - container_name: gpt-load + app: + build: + context: . + dockerfile: Dockerfile + container_name: gpt-load-app ports: - - "3000:3000" - volumes: - # 挂载密钥文件(只读) - - ./keys.txt:/app/keys.txt:ro - restart: unless-stopped + - "8080:8080" env_file: - .env + environment: + # Override or set environment variables here + - DB_HOST=db + - DB_PORT=3306 + - DB_USER=${DB_USER} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME} + - GIN_MODE=release + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + networks: + - gpt-load-net - # 健康检查 + db: + image: mysql:8.0 + container_name: gpt-load-db + restart: unless-stopped + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - db-data:/var/lib/mysql + ports: + - "3306:3306" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 40s + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${DB_USER}", "-p${DB_PASSWORD}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gpt-load-net + + redis: + image: redis:7-alpine + container_name: gpt-load-redis + restart: unless-stopped + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gpt-load-net + +volumes: + db-data: + +networks: + gpt-load-net: + driver: bridge diff --git a/go.mod b/go.mod index dca9fac..ea16473 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,12 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/joho/godotenv v1.5.1 github.com/sirupsen/logrus v1.9.3 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.30.0 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect @@ -16,7 +19,10 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect @@ -30,7 +36,7 @@ require ( golang.org/x/crypto v0.9.0 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.8.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ba6af85..8b4e4c0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -21,12 +23,18 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -76,8 +84,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +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 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 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= @@ -88,4 +96,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +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= diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..71fc2e6 --- /dev/null +++ b/internal/channel/channel.go @@ -0,0 +1,14 @@ +package channel + +import ( + "gpt-load/internal/models" + + "github.com/gin-gonic/gin" +) + +// ChannelProxy defines the interface for different API channel proxies. +type ChannelProxy interface { + // Handle takes a context, an API key, and the original request, + // then forwards the request to the upstream service. + Handle(c *gin.Context, apiKey *models.APIKey, group *models.Group) +} \ No newline at end of file diff --git a/internal/channel/factory.go b/internal/channel/factory.go new file mode 100644 index 0000000..3f4deb1 --- /dev/null +++ b/internal/channel/factory.go @@ -0,0 +1,18 @@ +package channel + +import ( + "fmt" + "gpt-load/internal/models" +) + +// GetChannel returns a channel proxy based on the group's channel type. +func GetChannel(group *models.Group) (ChannelProxy, error) { + switch group.ChannelType { + case "openai": + return NewOpenAIChannel(group) + case "gemini": + return NewGeminiChannel(group) + default: + return nil, fmt.Errorf("unsupported channel type: %s", group.ChannelType) + } +} \ No newline at end of file diff --git a/internal/channel/gemini_channel.go b/internal/channel/gemini_channel.go new file mode 100644 index 0000000..d46b3a7 --- /dev/null +++ b/internal/channel/gemini_channel.go @@ -0,0 +1,55 @@ +package channel + +import ( + "fmt" + "gpt-load/internal/models" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +const GeminiBaseURL = "https://generativelanguage.googleapis.com" + +type GeminiChannel struct { + BaseURL *url.URL +} + +func NewGeminiChannel(group *models.Group) (*GeminiChannel, error) { + baseURL, err := url.Parse(GeminiBaseURL) + if err != nil { + return nil, err // Should not happen with a constant + } + return &GeminiChannel{BaseURL: baseURL}, nil +} + +func (ch *GeminiChannel) Handle(c *gin.Context, apiKey *models.APIKey, group *models.Group) { + proxy := httputil.NewSingleHostReverseProxy(ch.BaseURL) + + proxy.Director = func(req *http.Request) { + // Gemini API key is passed as a query parameter + originalPath := c.Param("path") + newPath := fmt.Sprintf("%s?key=%s", originalPath, apiKey.KeyValue) + + req.URL.Scheme = ch.BaseURL.Scheme + req.URL.Host = ch.BaseURL.Host + req.URL.Path = newPath + req.Host = ch.BaseURL.Host + // Remove the Authorization header if it was passed by the client + req.Header.Del("Authorization") + } + + proxy.ModifyResponse = func(resp *http.Response) error { + // Log the response, etc. + return nil + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logrus.Errorf("Proxy error to Gemini: %v", err) + // Handle error, maybe update key status + } + + proxy.ServeHTTP(c.Writer, c.Request) +} \ No newline at end of file diff --git a/internal/channel/openai_channel.go b/internal/channel/openai_channel.go new file mode 100644 index 0000000..6f566f9 --- /dev/null +++ b/internal/channel/openai_channel.go @@ -0,0 +1,55 @@ +package channel + +import ( + "encoding/json" + "gpt-load/internal/models" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +type OpenAIChannel struct { + BaseURL *url.URL +} + +type OpenAIChannelConfig struct { + BaseURL string `json:"base_url"` +} + +func NewOpenAIChannel(group *models.Group) (*OpenAIChannel, error) { + var config OpenAIChannelConfig + if err := json.Unmarshal([]byte(group.Config), &config); err != nil { + return nil, err + } + baseURL, err := url.Parse(config.BaseURL) + if err != nil { + return nil, err + } + return &OpenAIChannel{BaseURL: baseURL}, nil +} + +func (ch *OpenAIChannel) Handle(c *gin.Context, apiKey *models.APIKey, group *models.Group) { + proxy := httputil.NewSingleHostReverseProxy(ch.BaseURL) + proxy.Director = func(req *http.Request) { + req.URL.Scheme = ch.BaseURL.Scheme + req.URL.Host = ch.BaseURL.Host + req.URL.Path = c.Param("path") + req.Host = ch.BaseURL.Host + req.Header.Set("Authorization", "Bearer "+apiKey.KeyValue) + } + + proxy.ModifyResponse = func(resp *http.Response) error { + // Log the response, etc. + return nil + } + + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logrus.Errorf("Proxy error: %v", err) + // Handle error, maybe update key status + } + + proxy.ServeHTTP(c.Writer, c.Request) +} \ No newline at end of file diff --git a/internal/config/manager.go b/internal/config/manager.go index 60f2c1f..bf3008e 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -55,6 +55,15 @@ type Config struct { // NewManager creates a new configuration manager func NewManager() (types.ConfigManager, error) { + manager := &Manager{} + if err := manager.ReloadConfig(); err != nil { + return nil, err + } + return manager, nil +} + +// ReloadConfig reloads the configuration from environment variables +func (m *Manager) ReloadConfig() error { // Try to load .env file if err := godotenv.Load(); err != nil { logrus.Info("Info: Create .env file to support environment variable configuration") @@ -104,15 +113,17 @@ func NewManager() (types.ConfigManager, error) { EnableRequest: parseBoolean(os.Getenv("LOG_ENABLE_REQUEST"), true), }, } - - manager := &Manager{config: config} + m.config = config // Validate configuration - if err := manager.Validate(); err != nil { - return nil, err + if err := m.Validate(); err != nil { + return err } - return manager, nil + logrus.Info("Configuration reloaded successfully") + m.DisplayConfig() + + return nil } // GetServerConfig returns server configuration diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..c7bb454 --- /dev/null +++ b/internal/db/database.go @@ -0,0 +1,62 @@ +package db + +import ( + "fmt" + "gpt-load/internal/models" + "log" + "os" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func InitDB() (*gorm.DB, error) { + // TODO: 从配置中心读取DSN + dsn := "root:1236@tcp(127.0.0.1:3306)/gpt_load?charset=utf8mb4&parseTime=True&loc=Local" + + newLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer + logger.Config{ + SlowThreshold: time.Second, // Slow SQL threshold + LogLevel: logger.Info, // Log level + IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger + Colorful: true, // Disable color + }, + ) + + var err error + DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: newLogger, + }) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + sqlDB, err := DB.DB() + if err != nil { + return nil, fmt.Errorf("failed to get sql.DB: %w", err) + } + + // Set connection pool parameters + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + // Auto-migrate models + err = DB.AutoMigrate( + &models.SystemSetting{}, + &models.Group{}, + &models.APIKey{}, + &models.RequestLog{}, + ) + if err != nil { + return nil, fmt.Errorf("failed to auto-migrate database: %w", err) + } + + fmt.Println("Database connection initialized and models migrated.") + return DB, nil +} diff --git a/internal/handler/dashboard_handler.go b/internal/handler/dashboard_handler.go new file mode 100644 index 0000000..1a0c483 --- /dev/null +++ b/internal/handler/dashboard_handler.go @@ -0,0 +1,62 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gpt-load/internal/db" + "gpt-load/internal/models" + "gpt-load/internal/response" +) + +// GetDashboardStats godoc +// @Summary Get dashboard statistics +// @Description Get statistics for the dashboard, including total requests, success rate, and group distribution. +// @Tags Dashboard +// @Accept json +// @Produce json +// @Success 200 {object} models.DashboardStats +// @Router /api/dashboard/stats [get] +func GetDashboardStats(c *gin.Context) { + var totalRequests, successRequests int64 + var groupStats []models.GroupRequestStat + + // Get total requests + if err := db.DB.Model(&models.RequestLog{}).Count(&totalRequests).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get total requests") + return + } + + // Get success requests (status code 2xx) + if err := db.DB.Model(&models.RequestLog{}).Where("status_code >= ? AND status_code < ?", 200, 300).Count(&successRequests).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get success requests") + return + } + + // Calculate success rate + var successRate float64 + if totalRequests > 0 { + successRate = float64(successRequests) / float64(totalRequests) + } + + // Get group stats + err := db.DB.Table("request_logs"). + Select("groups.name as group_name, count(request_logs.id) as request_count"). + Joins("join groups on groups.id = request_logs.group_id"). + Group("groups.name"). + Order("request_count desc"). + Scan(&groupStats).Error + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get group stats") + return + } + + stats := models.DashboardStats{ + TotalRequests: totalRequests, + SuccessRequests: successRequests, + SuccessRate: successRate, + GroupStats: groupStats, + } + + response.Success(c, stats) +} \ No newline at end of file diff --git a/internal/handler/group_handler.go b/internal/handler/group_handler.go new file mode 100644 index 0000000..fd27ae9 --- /dev/null +++ b/internal/handler/group_handler.go @@ -0,0 +1,125 @@ +// Package handler provides HTTP handlers for the application +package handler + +import ( + "gpt-load/internal/models" + "gpt-load/internal/response" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// CreateGroup handles the creation of a new group. +func (s *Server) CreateGroup(c *gin.Context) { + var group models.Group + if err := c.ShouldBindJSON(&group); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid request body") + return + } + + if err := s.DB.Create(&group).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to create group") + return + } + + response.Success(c, group) +} + +// ListGroups handles listing all groups. +func (s *Server) ListGroups(c *gin.Context) { + var groups []models.Group + if err := s.DB.Find(&groups).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to list groups") + return + } + response.Success(c, groups) +} + +// GetGroup handles getting a single group by its ID. +func (s *Server) GetGroup(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid group ID") + return + } + + var group models.Group + if err := s.DB.Preload("APIKeys").First(&group, id).Error; err != nil { + response.Error(c, http.StatusNotFound, "Group not found") + return + } + + response.Success(c, group) +} + +// UpdateGroup handles updating an existing group. +func (s *Server) UpdateGroup(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid group ID") + return + } + + var group models.Group + if err := s.DB.First(&group, id).Error; err != nil { + response.Error(c, http.StatusNotFound, "Group not found") + return + } + + var updateData models.Group + if err := c.ShouldBindJSON(&updateData); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid request body") + return + } + + // We only allow updating certain fields + group.Name = updateData.Name + group.Description = updateData.Description + group.ChannelType = updateData.ChannelType + group.Config = updateData.Config + + if err := s.DB.Save(&group).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to update group") + return + } + + response.Success(c, group) +} + +// DeleteGroup handles deleting a group. +func (s *Server) DeleteGroup(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid group ID") + return + } + + // Use a transaction to ensure atomicity + tx := s.DB.Begin() + if tx.Error != nil { + response.Error(c, http.StatusInternalServerError, "Failed to start transaction") + return + } + + // Also delete associated API keys + if err := tx.Where("group_id = ?", id).Delete(&models.APIKey{}).Error; err != nil { + tx.Rollback() + response.Error(c, http.StatusInternalServerError, "Failed to delete associated API keys") + return + } + + if err := tx.Delete(&models.Group{}, id).Error; err != nil { + tx.Rollback() + response.Error(c, http.StatusInternalServerError, "Failed to delete group") + return + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + response.Error(c, http.StatusInternalServerError, "Failed to commit transaction") + return + } + + response.Success(c, gin.H{"message": "Group and associated keys deleted successfully"}) +} \ No newline at end of file diff --git a/internal/handler/handler.go b/internal/handler/handler.go index bf1902c..c4697d1 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -6,35 +6,80 @@ import ( "runtime" "time" + "gpt-load/internal/models" "gpt-load/internal/types" "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" + "gorm.io/gorm" ) -// Handler contains dependencies for HTTP handlers -type Handler struct { - keyManager types.KeyManager - config types.ConfigManager +// Server contains dependencies for HTTP handlers +type Server struct { + DB *gorm.DB + config types.ConfigManager } -// NewHandler creates a new handler instance -func NewHandler(keyManager types.KeyManager, config types.ConfigManager) *Handler { - return &Handler{ - keyManager: keyManager, - config: config, +// NewServer creates a new handler instance +func NewServer(db *gorm.DB, config types.ConfigManager) *Server { + return &Server{ + DB: db, + config: config, } } +// RegisterAPIRoutes registers all API routes under a given router group +func (s *Server) RegisterAPIRoutes(api *gin.RouterGroup) { + // Group management routes + groups := api.Group("/groups") + { + groups.POST("", s.CreateGroup) + groups.GET("", s.ListGroups) + groups.GET("/:id", s.GetGroup) + groups.PUT("/:id", s.UpdateGroup) + groups.DELETE("/:id", s.DeleteGroup) + + // Key management routes within a group + keys := groups.Group("/:id/keys") + { + keys.POST("", s.CreateKeysInGroup) + keys.GET("", s.ListKeysInGroup) + } + } + + // Key management routes + api.PUT("/keys/:key_id", s.UpdateKey) + api.DELETE("/keys", s.DeleteKeys) + + // Dashboard and logs routes + dashboard := api.Group("/dashboard") + { + dashboard.GET("/stats", GetDashboardStats) + } + + api.GET("/logs", GetLogs) + + // Settings routes + settings := api.Group("/settings") + { + settings.GET("", GetSettings) + settings.PUT("", UpdateSettings) + } + + // Reload route + api.POST("/reload", s.ReloadConfig) +} + // Health handles health check requests -func (h *Handler) Health(c *gin.Context) { - stats := h.keyManager.GetStats() +func (s *Server) Health(c *gin.Context) { + var totalKeys, healthyKeys int64 + s.DB.Model(&models.APIKey{}).Count(&totalKeys) + s.DB.Model(&models.APIKey{}).Where("status = ?", "active").Count(&healthyKeys) status := "healthy" httpStatus := http.StatusOK // Check if there are any healthy keys - if stats.HealthyKeys == 0 { + if healthyKeys == 0 && totalKeys > 0 { status = "unhealthy" httpStatus = http.StatusServiceUnavailable } @@ -50,15 +95,23 @@ func (h *Handler) Health(c *gin.Context) { c.JSON(httpStatus, gin.H{ "status": status, "timestamp": time.Now().UTC().Format(time.RFC3339), - "healthy_keys": stats.HealthyKeys, - "total_keys": stats.TotalKeys, + "healthy_keys": healthyKeys, + "total_keys": totalKeys, "uptime": uptime, }) } // Stats handles statistics requests -func (h *Handler) Stats(c *gin.Context) { - stats := h.keyManager.GetStats() +func (s *Server) Stats(c *gin.Context) { + var totalKeys, healthyKeys, disabledKeys int64 + s.DB.Model(&models.APIKey{}).Count(&totalKeys) + s.DB.Model(&models.APIKey{}).Where("status = ?", "active").Count(&healthyKeys) + s.DB.Model(&models.APIKey{}).Where("status != ?", "active").Count(&disabledKeys) + + // TODO: Get request counts from the database + var successCount, failureCount int64 + s.DB.Model(&models.RequestLog{}).Where("status_code = ?", http.StatusOK).Count(&successCount) + s.DB.Model(&models.RequestLog{}).Where("status_code != ?", http.StatusOK).Count(&failureCount) // Add additional system information var m runtime.MemStats @@ -66,15 +119,14 @@ func (h *Handler) Stats(c *gin.Context) { response := gin.H{ "keys": gin.H{ - "total": stats.TotalKeys, - "healthy": stats.HealthyKeys, - "blacklisted": stats.BlacklistedKeys, - "current_index": stats.CurrentIndex, + "total": totalKeys, + "healthy": healthyKeys, + "disabled": disabledKeys, }, "requests": gin.H{ - "success_count": stats.SuccessCount, - "failure_count": stats.FailureCount, - "total_count": stats.SuccessCount + stats.FailureCount, + "success_count": successCount, + "failure_count": failureCount, + "total_count": successCount + failureCount, }, "memory": gin.H{ "alloc_mb": bToMb(m.Alloc), @@ -95,48 +147,9 @@ func (h *Handler) Stats(c *gin.Context) { c.JSON(http.StatusOK, response) } -// Blacklist handles blacklist requests -func (h *Handler) Blacklist(c *gin.Context) { - blacklist := h.keyManager.GetBlacklist() - - response := gin.H{ - "blacklisted_keys": blacklist, - "count": len(blacklist), - "timestamp": time.Now().UTC().Format(time.RFC3339), - } - - c.JSON(http.StatusOK, response) -} - -// ResetKeys handles key reset requests -func (h *Handler) ResetKeys(c *gin.Context) { - // Reset blacklist - h.keyManager.ResetBlacklist() - - // Reload keys from file - if err := h.keyManager.LoadKeys(); err != nil { - logrus.Errorf("Failed to reload keys: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to reload keys", - "message": err.Error(), - }) - return - } - - stats := h.keyManager.GetStats() - - c.JSON(http.StatusOK, gin.H{ - "message": "Keys reset and reloaded successfully", - "total_keys": stats.TotalKeys, - "healthy_keys": stats.HealthyKeys, - "timestamp": time.Now().UTC().Format(time.RFC3339), - }) - - logrus.Info("Keys reset and reloaded successfully") -} // MethodNotAllowed handles 405 requests -func (h *Handler) MethodNotAllowed(c *gin.Context) { +func (s *Server) MethodNotAllowed(c *gin.Context) { c.JSON(http.StatusMethodNotAllowed, gin.H{ "error": "Method not allowed", "path": c.Request.URL.Path, @@ -146,7 +159,7 @@ func (h *Handler) MethodNotAllowed(c *gin.Context) { } // GetConfig returns configuration information (for debugging) -func (h *Handler) GetConfig(c *gin.Context) { +func (s *Server) GetConfig(c *gin.Context) { // Only allow in development mode or with special header if c.GetHeader("X-Debug-Config") != "true" { c.JSON(http.StatusForbidden, gin.H{ @@ -155,13 +168,13 @@ func (h *Handler) GetConfig(c *gin.Context) { return } - serverConfig := h.config.GetServerConfig() - keysConfig := h.config.GetKeysConfig() - openaiConfig := h.config.GetOpenAIConfig() - authConfig := h.config.GetAuthConfig() - corsConfig := h.config.GetCORSConfig() - perfConfig := h.config.GetPerformanceConfig() - logConfig := h.config.GetLogConfig() + serverConfig := s.config.GetServerConfig() + keysConfig := s.config.GetKeysConfig() + openaiConfig := s.config.GetOpenAIConfig() + authConfig := s.config.GetAuthConfig() + corsConfig := s.config.GetCORSConfig() + perfConfig := s.config.GetPerformanceConfig() + logConfig := s.config.GetLogConfig() // Sanitize sensitive information sanitizedConfig := gin.H{ diff --git a/internal/handler/key_handler.go b/internal/handler/key_handler.go new file mode 100644 index 0000000..f1e4be2 --- /dev/null +++ b/internal/handler/key_handler.go @@ -0,0 +1,119 @@ +// Package handler provides HTTP handlers for the application +package handler + +import ( + "gpt-load/internal/models" + "gpt-load/internal/response" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +type CreateKeysRequest struct { + Keys []string `json:"keys" binding:"required"` +} + +// CreateKeysInGroup handles creating new keys within a specific group. +func (s *Server) CreateKeysInGroup(c *gin.Context) { + groupID, err := strconv.Atoi(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid group ID") + return + } + + var req CreateKeysRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid request body") + return + } + + var newKeys []models.APIKey + for _, keyVal := range req.Keys { + newKeys = append(newKeys, models.APIKey{ + GroupID: uint(groupID), + KeyValue: keyVal, + Status: "active", + }) + } + + if err := s.DB.Create(&newKeys).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to create keys") + return + } + + response.Success(c, newKeys) +} + +// ListKeysInGroup handles listing all keys within a specific group. +func (s *Server) ListKeysInGroup(c *gin.Context) { + groupID, err := strconv.Atoi(c.Param("id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid group ID") + return + } + + var keys []models.APIKey + if err := s.DB.Where("group_id = ?", groupID).Find(&keys).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to list keys") + return + } + + response.Success(c, keys) +} + +// UpdateKey handles updating a specific key. +func (s *Server) UpdateKey(c *gin.Context) { + keyID, err := strconv.Atoi(c.Param("key_id")) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid key ID") + return + } + + var key models.APIKey + if err := s.DB.First(&key, keyID).Error; err != nil { + response.Error(c, http.StatusNotFound, "Key not found") + return + } + + var updateData struct { + Status string `json:"status"` + } + if err := c.ShouldBindJSON(&updateData); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid request body") + return + } + + key.Status = updateData.Status + if err := s.DB.Save(&key).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to update key") + return + } + + response.Success(c, key) +} + +type DeleteKeysRequest struct { + KeyIDs []uint `json:"key_ids" binding:"required"` +} + +// DeleteKeys handles deleting one or more keys. +func (s *Server) DeleteKeys(c *gin.Context) { + var req DeleteKeysRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid request body") + return + } + + if len(req.KeyIDs) == 0 { + response.Error(c, http.StatusBadRequest, "No key IDs provided") + return + } + + if err := s.DB.Delete(&models.APIKey{}, req.KeyIDs).Error; err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to delete keys") + return + } + + response.Success(c, gin.H{"message": "Keys deleted successfully"}) +} \ No newline at end of file diff --git a/internal/handler/log_handler.go b/internal/handler/log_handler.go new file mode 100644 index 0000000..d2a138f --- /dev/null +++ b/internal/handler/log_handler.go @@ -0,0 +1,79 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gpt-load/internal/db" + "gpt-load/internal/models" + "gpt-load/internal/response" +) + +// GetLogs godoc +// @Summary Get request logs +// @Description Get request logs with pagination and filtering +// @Tags Logs +// @Accept json +// @Produce json +// @Param page query int false "Page number" +// @Param size query int false "Page size" +// @Param group_id query int false "Group ID" +// @Param start_time query string false "Start time (RFC3339)" +// @Param end_time query string false "End time (RFC3339)" +// @Param status_code query int false "Status code" +// @Success 200 {array} models.RequestLog +// @Router /api/logs [get] +func GetLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + offset := (page - 1) * size + + query := db.DB.Model(&models.RequestLog{}) + + if groupIDStr := c.Query("group_id"); groupIDStr != "" { + groupID, err := strconv.Atoi(groupIDStr) + if err == nil { + query = query.Where("group_id = ?", groupID) + } + } + + if startTimeStr := c.Query("start_time"); startTimeStr != "" { + startTime, err := time.Parse(time.RFC3339, startTimeStr) + if err == nil { + query = query.Where("timestamp >= ?", startTime) + } + } + + if endTimeStr := c.Query("end_time"); endTimeStr != "" { + endTime, err := time.Parse(time.RFC3339, endTimeStr) + if err == nil { + query = query.Where("timestamp <= ?", endTime) + } + } + + if statusCodeStr := c.Query("status_code"); statusCodeStr != "" { + statusCode, err := strconv.Atoi(statusCodeStr) + if err == nil { + query = query.Where("status_code = ?", statusCode) + } + } + + var logs []models.RequestLog + var total int64 + + query.Count(&total) + err := query.Order("timestamp desc").Offset(offset).Limit(size).Find(&logs).Error + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get logs") + return + } + + c.JSON(http.StatusOK, gin.H{ + "total": total, + "page": page, + "size": size, + "data": logs, + }) +} \ No newline at end of file diff --git a/internal/handler/reload_handler.go b/internal/handler/reload_handler.go new file mode 100644 index 0000000..e2cb283 --- /dev/null +++ b/internal/handler/reload_handler.go @@ -0,0 +1,26 @@ +package handler + +import ( + "gpt-load/internal/response" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// ReloadConfig handles the POST /api/reload request. +// It triggers a configuration reload. +func (s *Server) ReloadConfig(c *gin.Context) { + if s.config == nil { + response.InternalError(c, "Configuration manager is not initialized") + return + } + + err := s.config.ReloadConfig() + if err != nil { + logrus.Errorf("Failed to reload config: %v", err) + response.InternalError(c, "Failed to reload config") + return + } + + response.Success(c, gin.H{"message": "Configuration reloaded successfully"}) +} diff --git a/internal/handler/settings_handler.go b/internal/handler/settings_handler.go new file mode 100644 index 0000000..f422977 --- /dev/null +++ b/internal/handler/settings_handler.go @@ -0,0 +1,62 @@ +package handler + +import ( + "gpt-load/internal/db" + "gpt-load/internal/models" + "gpt-load/internal/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm/clause" +) + +// GetSettings handles the GET /api/settings request. +// It retrieves all system settings from the database and returns them as a key-value map. +func GetSettings(c *gin.Context) { + var settings []models.SystemSetting + if err := db.DB.Find(&settings).Error; err != nil { + response.InternalError(c, "Failed to retrieve settings") + return + } + + settingsMap := make(map[string]string) + for _, s := range settings { + settingsMap[s.SettingKey] = s.SettingValue + } + + response.Success(c, settingsMap) +} + +// UpdateSettings handles the PUT /api/settings request. +// It receives a key-value JSON object and updates or creates settings in the database. +func UpdateSettings(c *gin.Context) { + var settingsMap map[string]string + if err := c.ShouldBindJSON(&settingsMap); err != nil { + response.BadRequest(c, "Invalid request body") + return + } + + var settingsToUpdate []models.SystemSetting + for key, value := range settingsMap { + settingsToUpdate = append(settingsToUpdate, models.SystemSetting{ + SettingKey: key, + SettingValue: value, + }) + } + + if len(settingsToUpdate) == 0 { + response.Success(c, nil) + return + } + + // Using OnConflict to perform an "upsert" operation. + // If a setting with the same key exists, it will be updated. Otherwise, a new one will be created. + if err := db.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "setting_key"}}, + DoUpdates: clause.AssignmentColumns([]string{"setting_value"}), + }).Create(&settingsToUpdate).Error; err != nil { + response.InternalError(c, "Failed to update settings") + return + } + + response.Success(c, nil) +} \ No newline at end of file diff --git a/internal/models/types.go b/internal/models/types.go new file mode 100644 index 0000000..8c5481e --- /dev/null +++ b/internal/models/types.go @@ -0,0 +1,66 @@ +package models + +import ( + "time" +) + +// SystemSetting 对应 system_settings 表 +type SystemSetting struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + SettingKey string `gorm:"type:varchar(255);not null;unique" json:"setting_key"` + SettingValue string `gorm:"type:text;not null" json:"setting_value"` + Description string `gorm:"type:varchar(512)" json:"description"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Group 对应 groups 表 +type Group struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(255);not null;unique" json:"name"` + Description string `gorm:"type:varchar(512)" json:"description"` + ChannelType string `gorm:"type:varchar(50);not null" json:"channel_type"` + Config string `gorm:"type:json" json:"config"` + APIKeys []APIKey `gorm:"foreignKey:GroupID" json:"api_keys"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// APIKey 对应 api_keys 表 +type APIKey struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + GroupID uint `gorm:"not null" json:"group_id"` + KeyValue string `gorm:"type:varchar(512);not null" json:"key_value"` + Status string `gorm:"type:varchar(50);not null;default:'active'" json:"status"` + RequestCount int64 `gorm:"not null;default:0" json:"request_count"` + FailureCount int64 `gorm:"not null;default:0" json:"failure_count"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RequestLog 对应 request_logs 表 +type RequestLog struct { + ID string `gorm:"type:varchar(36);primaryKey" json:"id"` + Timestamp time.Time `gorm:"type:datetime(3);not null" json:"timestamp"` + GroupID uint `gorm:"not null" json:"group_id"` + KeyID uint `gorm:"not null" json:"key_id"` + SourceIP string `gorm:"type:varchar(45)" json:"source_ip"` + StatusCode int `gorm:"not null" json:"status_code"` + RequestPath string `gorm:"type:varchar(1024)" json:"request_path"` + RequestBodySnippet string `gorm:"type:text" json:"request_body_snippet"` +} + +// GroupRequestStat 用于表示每个分组的请求统计 +type GroupRequestStat struct { + GroupName string `json:"group_name"` + RequestCount int64 `json:"request_count"` +} + +// DashboardStats 用于仪表盘的统计数据 +type DashboardStats struct { + TotalRequests int64 `json:"total_requests"` + SuccessRequests int64 `json:"success_requests"` + SuccessRate float64 `json:"success_rate"` + GroupStats []GroupRequestStat `json:"group_stats"` +} \ No newline at end of file diff --git a/internal/proxy/server.go b/internal/proxy/server.go index bcebe2e..bb529a2 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -2,498 +2,118 @@ package proxy import ( - "bufio" - "bytes" - "compress/gzip" - "context" - "encoding/json" "fmt" - "io" + "gpt-load/internal/channel" + "gpt-load/internal/models" + "gpt-load/internal/response" "net/http" - "net/url" - "strings" - "sync/atomic" + "sync" "time" - "gpt-load/internal/errors" - "gpt-load/internal/types" - "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) -// A list of errors that are considered normal during streaming when a client disconnects. -var ignorableStreamErrors = []string{ - "context canceled", - "connection reset by peer", - "broken pipe", - "use of closed network connection", -} - -// isIgnorableStreamError checks if the error is a common, non-critical error that can occur -// when a client disconnects during a streaming response. -func isIgnorableStreamError(err error) bool { - errStr := err.Error() - for _, ignorableError := range ignorableStreamErrors { - if strings.Contains(errStr, ignorableError) { - return true - } - } - return false -} - // ProxyServer represents the proxy server type ProxyServer struct { - keyManager types.KeyManager - configManager types.ConfigManager - httpClient *http.Client - streamClient *http.Client // Dedicated client for streaming - requestCount int64 - startTime time.Time + DB *gorm.DB + groupCounters sync.Map // For round-robin key selection + requestLogChan chan models.RequestLog } // NewProxyServer creates a new proxy server -func NewProxyServer(keyManager types.KeyManager, configManager types.ConfigManager) (*ProxyServer, error) { - openaiConfig := configManager.GetOpenAIConfig() - perfConfig := configManager.GetPerformanceConfig() - - // Create high-performance HTTP client - transport := &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 20, - MaxConnsPerHost: 0, - IdleConnTimeout: time.Duration(openaiConfig.IdleConnTimeout) * time.Second, - TLSHandshakeTimeout: 15 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - DisableCompression: !perfConfig.EnableGzip, - ForceAttemptHTTP2: true, - WriteBufferSize: 32 * 1024, - ReadBufferSize: 32 * 1024, - } - - // Create dedicated transport for streaming, optimize TCP parameters - streamTransport := &http.Transport{ - MaxIdleConns: 200, - MaxIdleConnsPerHost: 40, - MaxConnsPerHost: 0, - IdleConnTimeout: time.Duration(openaiConfig.IdleConnTimeout) * time.Second, - TLSHandshakeTimeout: 15 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - DisableCompression: true, - ForceAttemptHTTP2: true, - WriteBufferSize: 0, - ReadBufferSize: 0, - ResponseHeaderTimeout: time.Duration(openaiConfig.ResponseTimeout) * time.Second, - } - - httpClient := &http.Client{ - Transport: transport, - Timeout: time.Duration(openaiConfig.RequestTimeout) * time.Second, - } - - // Streaming client without overall timeout - streamClient := &http.Client{ - Transport: streamTransport, - } - +func NewProxyServer(db *gorm.DB, requestLogChan chan models.RequestLog) (*ProxyServer, error) { return &ProxyServer{ - keyManager: keyManager, - configManager: configManager, - httpClient: httpClient, - streamClient: streamClient, - startTime: time.Now(), + DB: db, + groupCounters: sync.Map{}, + requestLogChan: requestLogChan, }, nil } -// HandleProxy handles proxy requests +// RegisterProxyRoutes registers the main proxy route under a given router group +func (ps *ProxyServer) RegisterProxyRoutes(proxy *gin.RouterGroup) { + proxy.Any("/:group_name/*path", ps.HandleProxy) +} + +// HandleProxy handles the main proxy logic func (ps *ProxyServer) HandleProxy(c *gin.Context) { startTime := time.Now() + groupName := c.Param("group_name") - // Increment request count - atomic.AddInt64(&ps.requestCount, 1) - - // Cache all request body upfront - var bodyBytes []byte - if c.Request.Body != nil { - var err error - bodyBytes, err = io.ReadAll(c.Request.Body) - if err != nil { - logrus.Errorf("Failed to read request body: %v", err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Failed to read request body", - "code": errors.ErrProxyRequest, - }) - return - } - } - - // Determine if this is a streaming request using cached data - isStreamRequest := ps.isStreamRequest(bodyBytes, c) - - // Execute request with retry - ps.executeRequestWithRetry(c, startTime, bodyBytes, isStreamRequest, 0, nil) -} - -// isStreamRequest determines if this is a streaming request -func (ps *ProxyServer) isStreamRequest(bodyBytes []byte, c *gin.Context) bool { - // Check for Gemini streaming endpoint - if strings.HasSuffix(c.Request.URL.Path, ":streamGenerateContent") { - return true - } - - // Check Accept header - if strings.Contains(c.GetHeader("Accept"), "text/event-stream") { - return true - } - - // Check URL query parameter - if c.Query("stream") == "true" { - return true - } - - // Check stream parameter in request body - if len(bodyBytes) > 0 { - var bodyJSON map[string]interface{} - if err := json.Unmarshal(bodyBytes, &bodyJSON); err == nil { - if stream, ok := bodyJSON["stream"].(bool); ok && stream { - return true - } - } - } - - return false -} - -// executeRequestWithRetry executes request with retry logic -func (ps *ProxyServer) executeRequestWithRetry(c *gin.Context, startTime time.Time, bodyBytes []byte, isStreamRequest bool, retryCount int, retryErrors []types.RetryError) { - keysConfig := ps.configManager.GetKeysConfig() - - if retryCount > keysConfig.MaxRetries { - logrus.Debugf("Max retries exceeded (%d)", retryCount-1) - - errorResponse := gin.H{ - "error": "Max retries exceeded", - "code": errors.ErrProxyRetryExhausted, - "retry_count": retryCount - 1, - "retry_errors": retryErrors, - "timestamp": time.Now().UTC().Format(time.RFC3339), - } - - statusCode := http.StatusBadGateway - if len(retryErrors) > 0 && retryErrors[len(retryErrors)-1].StatusCode > 0 { - statusCode = retryErrors[len(retryErrors)-1].StatusCode - } - - c.JSON(statusCode, errorResponse) + // 1. Find the group by name + var group models.Group + if err := ps.DB.Preload("APIKeys").Where("name = ?", groupName).First(&group).Error; err != nil { + response.Error(c, http.StatusNotFound, fmt.Sprintf("Group '%s' not found", groupName)) return } - // Get key information - keyInfo, err := ps.keyManager.GetNextKey() + // 2. Select an available API key from the group + apiKey, err := ps.selectAPIKey(&group) if err != nil { - logrus.Errorf("Failed to get key: %v", err) - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "No API keys available", - "code": errors.ErrNoKeysAvailable, - }) + response.Error(c, http.StatusServiceUnavailable, err.Error()) return } - // Set key information to context (for logging) - c.Set("keyIndex", keyInfo.Index) - c.Set("keyPreview", keyInfo.Preview) - - // Set retry information to context - if retryCount > 0 { - c.Set("retryCount", retryCount) - } - - // Get a base URL from the config manager (handles round-robin) - openaiConfig := ps.configManager.GetOpenAIConfig() - upstreamURL, err := url.Parse(openaiConfig.BaseURL) + // 3. Get the appropriate channel handler from the factory + channelHandler, err := channel.GetChannel(&group) if err != nil { - logrus.Errorf("Failed to parse upstream URL: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Invalid upstream URL configured", - "code": errors.ErrConfigInvalid, - }) + response.Error(c, http.StatusInternalServerError, fmt.Sprintf("Failed to get channel for group '%s': %v", groupName, err)) return } - // Build upstream request URL - targetURL := *upstreamURL - // Correctly append path instead of replacing it - if strings.HasSuffix(targetURL.Path, "/") { - targetURL.Path = targetURL.Path + strings.TrimPrefix(c.Request.URL.Path, "/") - } else { - targetURL.Path = targetURL.Path + c.Request.URL.Path - } - targetURL.RawQuery = c.Request.URL.RawQuery + // 4. Forward the request using the channel handler + channelHandler.Handle(c, apiKey, &group) - // Use different timeout strategies for streaming and non-streaming requests - var ctx context.Context - var cancel context.CancelFunc + // 5. Log the request asynchronously + go ps.logRequest(c, &group, apiKey, startTime) +} - if isStreamRequest { - // Streaming requests only set response header timeout, no overall timeout - ctx, cancel = context.WithCancel(c.Request.Context()) - } else { - // Non-streaming requests use configured timeout from the already fetched config - timeout := time.Duration(openaiConfig.RequestTimeout) * time.Second - ctx, cancel = context.WithTimeout(c.Request.Context(), timeout) - } - defer cancel() - - // Create request using cached bodyBytes - req, err := http.NewRequestWithContext( - ctx, - c.Request.Method, - targetURL.String(), - bytes.NewReader(bodyBytes), - ) - if err != nil { - logrus.Errorf("Failed to create upstream request: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Failed to create upstream request", - "code": errors.ErrProxyRequest, - }) - return - } - req.ContentLength = int64(len(bodyBytes)) - - // Copy request headers - for key, values := range c.Request.Header { - if key != "Host" { - for _, value := range values { - req.Header.Add(key, value) - } +// selectAPIKey selects an API key from a group using round-robin +func (ps *ProxyServer) selectAPIKey(group *models.Group) (*models.APIKey, error) { + activeKeys := make([]models.APIKey, 0, len(group.APIKeys)) + for _, key := range group.APIKeys { + if key.Status == "active" { + activeKeys = append(activeKeys, key) } } - if c.GetHeader("Authorization") != "" { - req.Header.Set("Authorization", "Bearer "+keyInfo.Key) - req.Header.Del("X-Goog-Api-Key") - } else if c.GetHeader("X-Goog-Api-Key") != "" { - req.Header.Set("X-Goog-Api-Key", keyInfo.Key) - req.Header.Del("Authorization") - } else if c.Query("key") != "" { - q := req.URL.Query() - q.Set("key", keyInfo.Key) - req.URL.RawQuery = q.Encode() - } else { - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "API key required. Please provide a key in 'Authorization' or 'X-Goog-Api-Key' header.", - "code": errors.ErrAuthMissing, - }) - c.Abort() - return + if len(activeKeys) == 0 { + return nil, fmt.Errorf("no active API keys available in group '%s'", group.Name) } - // Choose appropriate client based on request type - var client *http.Client - if isStreamRequest { - client = ps.streamClient - // Add header to disable nginx buffering - req.Header.Set("X-Accel-Buffering", "no") - } else { - client = ps.httpClient + // Get the current counter for the group + counter, _ := ps.groupCounters.LoadOrStore(group.ID, uint64(0)) + currentCounter := counter.(uint64) + + // Select the key and increment the counter + selectedKey := activeKeys[int(currentCounter%uint64(len(activeKeys)))] + ps.groupCounters.Store(group.ID, currentCounter+1) + + return &selectedKey, nil +} + +func (ps *ProxyServer) logRequest(c *gin.Context, group *models.Group, key *models.APIKey, startTime time.Time) { + logEntry := models.RequestLog{ + ID: fmt.Sprintf("req_%d", time.Now().UnixNano()), + Timestamp: startTime, + GroupID: group.ID, + KeyID: key.ID, + SourceIP: c.ClientIP(), + StatusCode: c.Writer.Status(), + RequestPath: c.Request.URL.Path, + RequestBodySnippet: "", // Can be implemented later if needed } - // Send request - resp, err := client.Do(req) - if err != nil { - responseTime := time.Since(startTime) - - // Log failure - if retryCount > 0 { - logrus.Warnf("Retry request failed (attempt %d): %v (response time: %v)", retryCount+1, err, responseTime) - } else { - logrus.Warnf("Initial request failed: %v (response time: %v)", err, responseTime) - } - - // Record failure asynchronously - go ps.keyManager.RecordFailure(keyInfo.Key, err) - - // Record retry error information - if retryErrors == nil { - retryErrors = make([]types.RetryError, 0) - } - retryErrors = append(retryErrors, types.RetryError{ - StatusCode: 0, // Network error, no HTTP status code - ErrorMessage: err.Error(), - KeyIndex: keyInfo.Index, - Attempt: retryCount + 1, - }) - - // Retry - ps.executeRequestWithRetry(c, startTime, bodyBytes, isStreamRequest, retryCount+1, retryErrors) - return - } - defer resp.Body.Close() - - responseTime := time.Since(startTime) - - // Check if HTTP status code requires retry - if resp.StatusCode >= 400 { - // Log failure - if retryCount > 0 { - logrus.Debugf("Retry request returned error %d (attempt %d) (response time: %v)", resp.StatusCode, retryCount+1, responseTime) - } else { - logrus.Debugf("Initial request returned error %d (response time: %v)", resp.StatusCode, responseTime) - } - - // Read response body to get error information - var errorMessage string - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - errorMessage = fmt.Sprintf("HTTP %d (failed to read body: %v)", resp.StatusCode, err) - } else { - if resp.Header.Get("Content-Encoding") == "gzip" { - reader, gErr := gzip.NewReader(bytes.NewReader(bodyBytes)) - if gErr != nil { - errorMessage = fmt.Sprintf("gzip reader error: %v", gErr) - } else { - uncompressedBytes, rErr := io.ReadAll(reader) - reader.Close() - if rErr != nil { - errorMessage = fmt.Sprintf("gzip read error: %v", rErr) - } else { - errorMessage = string(uncompressedBytes) - } - } - } else { - errorMessage = string(bodyBytes) - } - } - - var jsonError struct { - Error struct { - Message string `json:"message"` - } `json:"error"` - } - - if err := json.Unmarshal([]byte(errorMessage), &jsonError); err == nil && jsonError.Error.Message != "" { - logrus.Warnf("Http Error: %s", jsonError.Error.Message) - } else { - logrus.Warnf("Http Error: %s", errorMessage) - } - - // Record failure asynchronously - go ps.keyManager.RecordFailure(keyInfo.Key, fmt.Errorf("HTTP %d", resp.StatusCode)) - - // Record retry error information - if retryErrors == nil { - retryErrors = make([]types.RetryError, 0) - } - retryErrors = append(retryErrors, types.RetryError{ - StatusCode: resp.StatusCode, - ErrorMessage: errorMessage, - KeyIndex: keyInfo.Index, - Attempt: retryCount + 1, - }) - - // Retry - ps.executeRequestWithRetry(c, startTime, bodyBytes, isStreamRequest, retryCount+1, retryErrors) - return - } - - // Success - record success asynchronously - go ps.keyManager.RecordSuccess(keyInfo.Key) - - // Log final success result - if retryCount > 0 { - logrus.Debugf("Request succeeded after %d retries (response time: %v)", retryCount, responseTime) - } else { - logrus.Debugf("Request succeeded on first attempt (response time: %v)", responseTime) - } - - // Copy response headers - for key, values := range resp.Header { - for _, value := range values { - c.Header(key, value) - } - } - - // Set status code - c.Status(resp.StatusCode) - - // Handle streaming and non-streaming responses - if isStreamRequest { - ps.handleStreamingResponse(c, resp) - } else { - ps.handleNormalResponse(c, resp) + // Send to the logging channel without blocking + select { + case ps.requestLogChan <- logEntry: + default: + logrus.Warn("Request log channel is full. Dropping log entry.") } } -var newline = []byte("\n") - -// handleStreamingResponse handles streaming responses -func (ps *ProxyServer) handleStreamingResponse(c *gin.Context, resp *http.Response) { - // Set headers for streaming - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("Content-Type", "text/event-stream") - c.Header("X-Accel-Buffering", "no") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - logrus.Error("Streaming unsupported") - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Streaming unsupported", - "code": errors.ErrServerInternal, - }) - return - } - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - for scanner.Scan() { - lineBytes := scanner.Bytes() - if _, err := c.Writer.Write(lineBytes); err != nil { - if isIgnorableStreamError(err) { - logrus.Debugf("Stream closed by client: %v", err) - } else { - logrus.Errorf("Failed to write streaming data: %v", err) - } - return - } - if _, err := c.Writer.Write(newline); err != nil { - if isIgnorableStreamError(err) { - logrus.Debugf("Stream closed by client: %v", err) - } else { - logrus.Errorf("Failed to write streaming data: %v", err) - } - return - } - - flusher.Flush() - } - - if err := scanner.Err(); err != nil { - if isIgnorableStreamError(err) { - logrus.Debugf("Stream closed by client or network: %v", err) - } else { - logrus.Errorf("Error reading streaming response: %v", err) - } - } -} - -// handleNormalResponse handles normal responses -func (ps *ProxyServer) handleNormalResponse(c *gin.Context, resp *http.Response) { - // Copy response body - if _, err := io.Copy(c.Writer, resp.Body); err != nil { - logrus.Errorf("Failed to copy response body: %v", err) - } -} - -// Close closes the proxy server and cleans up resources +// Close cleans up resources func (ps *ProxyServer) Close() { - // Close HTTP clients if needed - if ps.httpClient != nil { - ps.httpClient.CloseIdleConnections() - } - if ps.streamClient != nil { - ps.streamClient.CloseIdleConnections() - } + // Nothing to close for now } diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..3b733c6 --- /dev/null +++ b/internal/response/response.go @@ -0,0 +1,48 @@ +// Package response provides standardized JSON response helpers. +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Response defines the standard JSON response structure. +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// Success sends a standardized success response. +func Success(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Code: 0, + Message: "Success", + Data: data, + }) +} + +// Error sends a standardized error response. +func Error(c *gin.Context, code int, message string) { + c.JSON(code, Response{ + Code: code, + Message: message, + Data: nil, + }) +} + +// BadRequest sends a 400 Bad Request error response. +func BadRequest(c *gin.Context, message string) { + Error(c, http.StatusBadRequest, message) +} + +// NotFound sends a 404 Not Found error response. +func NotFound(c *gin.Context, message string) { + Error(c, http.StatusNotFound, message) +} + +// InternalError sends a 500 Internal Server Error response. +func InternalError(c *gin.Context, message string) { + Error(c, http.StatusInternalServerError, message) +} \ No newline at end of file diff --git a/internal/types/types.go b/internal/types/types.go index 5a75d4d..d46589e 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -18,6 +18,7 @@ type ConfigManager interface { GetLogConfig() LogConfig Validate() error DisplayConfig() + ReloadConfig() error } // KeyManager defines the interface for API key management diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..eef1c44 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2159 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "axios": "^1.10.0", + "echarts": "^5.6.0", + "element-plus": "^2.10.2", + "pinia": "^3.0.3", + "vue": "^3.5.17", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.0.7", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vite": "^7.0.0", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.27.7.tgz", + "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.27.7.tgz", + "integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz", + "integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-NYqRyg/hIQrYPT9lbOeYc3kIRabJDn/k4qQHIXUpx88CBDww2fD15Sg5kbXlW86zm2XEW4g0QxkTI3/Kfkc7xQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.7", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.0.7.tgz", + "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.0.tgz", + "integrity": "sha512-iAliE72WsdhjzTOp2DtvKThq1VBC4REhwRcaA+zPAAph6I+OQhUXv+Xu2KS7ElxYtb7Zc/3R30Hwv1DxEo7NXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.19" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.17.tgz", + "integrity": "sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.5", + "@vue/shared": "3.5.17", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.17.tgz", + "integrity": "sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.17.tgz", + "integrity": "sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.5", + "@vue/compiler-core": "3.5.17", + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.17.tgz", + "integrity": "sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.10", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.10.tgz", + "integrity": "sha512-+yNoYx6XIKuAO8Mqh1vGytu8jkFEOH5C8iOv3i8Z/65A7x9iAOXA97Q+PqZ3nlm2lxf5rOJuIGI/wDtx/riNYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.11", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.17.tgz", + "integrity": "sha512-l/rmw2STIscWi7SNJp708FK4Kofs97zc/5aEPQh4bOsReD/8ICuBcEmS7KGwDj5ODQLYWVN2lNibKJL1z5b+Lw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.17.tgz", + "integrity": "sha512-QQLXa20dHg1R0ri4bjKeGFKEkJA7MMBxrKo2G+gJikmumRS7PTD4BOU9FKrDQWMKowz7frJJGqBffYMgQYS96Q==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.17", + "@vue/shared": "3.5.17" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.17.tgz", + "integrity": "sha512-8El0M60TcwZ1QMz4/os2MdlQECgGoVHPuLnQBU3m9h3gdNRW9xRmI8iLS4t/22OQlOE6aJvNNlBiCzPHur4H9g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.17", + "@vue/runtime-core": "3.5.17", + "@vue/shared": "3.5.17", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.17.tgz", + "integrity": "sha512-BOHhm8HalujY6lmC3DbqF6uXN/K00uWiEeF22LfEsm9Q93XeJ/plHTepGwf6tqFcF7GA5oGSSAAUock3VvzaCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.17", + "@vue/shared": "3.5.17" + }, + "peerDependencies": { + "vue": "3.5.17" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.17.tgz", + "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.4.0.tgz", + "integrity": "sha512-5IdNxTyhXHv2UlgnPHQ0h+5ypVmkrYHzL8QT+DwFZ//2N/oNV8Ch+BCRmTJ3x6/z9Axo/cXYBc9eprsUVK/Jsg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.10.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.10.2.tgz", + "integrity": "sha512-p2KiAa0jEGXrzdlTAfpiS7HQFAhla4gvx6H7RuDf+OO0uC3DGpolxvdHjFR8gt7+vaWyxQNcHa1sAdBkmjqlgA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz", + "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.44.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.17", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.17.tgz", + "integrity": "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.17", + "@vue/compiler-sfc": "3.5.17", + "@vue/runtime-dom": "3.5.17", + "@vue/server-renderer": "3.5.17", + "@vue/shared": "3.5.17" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.5.1", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "2.2.10", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.10.tgz", + "integrity": "sha512-jWZ1xSaNbabEV3whpIDMbjVSVawjAyW+x1n3JeGQo7S0uv2n9F/JMgWW90tGWNFRKya4YwKMZgCtr0vRAM7DeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~2.4.11", + "@vue/language-core": "2.2.10" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f9c05a6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.10.0", + "echarts": "^5.6.0", + "element-plus": "^2.10.2", + "pinia": "^3.0.3", + "vue": "^3.5.17", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.0.7", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "typescript": "~5.8.3", + "vite": "^7.0.0", + "vue-tsc": "^2.2.10" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..68b4fd7 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/web/src/api/dashboard.ts b/web/src/api/dashboard.ts new file mode 100644 index 0000000..731e1c5 --- /dev/null +++ b/web/src/api/dashboard.ts @@ -0,0 +1,6 @@ +import request from './index'; +import type { DashboardStats } from '@/types/models'; + +export const getDashboardStats = (): Promise => { + return request.get('/api/dashboard/stats'); +}; \ No newline at end of file diff --git a/web/src/api/groups.ts b/web/src/api/groups.ts new file mode 100644 index 0000000..1400c0e --- /dev/null +++ b/web/src/api/groups.ts @@ -0,0 +1,42 @@ +import apiClient from './index'; +import type { Group } from '../types/models'; + +/** + * 获取所有分组列表 + */ +export const fetchGroups = (): Promise => { + return apiClient.get('/groups').then(res => res.data.data); +}; + +/** + * 获取单个分组的详细信息 + * @param id 分组ID + */ +export const fetchGroup = (id: string): Promise => { + return apiClient.get(`/groups/${id}`).then(res => res.data.data); +}; + +/** + * 创建一个新的分组 + * @param groupData 新分组的数据 + */ +export const createGroup = (groupData: Omit): Promise => { + return apiClient.post('/groups', groupData).then(res => res.data.data); +}; + +/** + * 更新一个已存在的分组 + * @param id 分组ID + * @param groupData 要更新的数据 + */ +export const updateGroup = (id: string, groupData: Partial>): Promise => { + return apiClient.put(`/groups/${id}`, groupData).then(res => res.data.data); +}; + +/** + * 删除一个分组 + * @param id 分组ID + */ +export const deleteGroup = (id: string): Promise => { + return apiClient.delete(`/groups/${id}`).then(res => res.data); +}; \ No newline at end of file diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..d019f36 --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,14 @@ +import axios from "axios"; + +const apiClient = axios.create({ + baseURL: "/", + headers: { + "Content-Type": "application/json", + }, +}); + +// 可以添加请求和响应拦截器 +// apiClient.interceptors.request.use(...) +// apiClient.interceptors.response.use(...) + +export default apiClient; diff --git a/web/src/api/keys.ts b/web/src/api/keys.ts new file mode 100644 index 0000000..6118cf7 --- /dev/null +++ b/web/src/api/keys.ts @@ -0,0 +1,36 @@ +import apiClient from './index'; +import type { Key } from '../types/models'; + +/** + * 获取指定分组下的所有密钥列表 + * @param groupId 分组ID + */ +export const fetchKeysInGroup = (groupId: string): Promise => { + return apiClient.get(`/groups/${groupId}/keys`).then(res => res.data.data); +}; + +/** + * 在指定分组下创建一个新的密钥 + * @param groupId 分组ID + * @param keyData 新密钥的数据 + */ +export const createKey = (groupId: string, keyData: Omit): Promise => { + return apiClient.post(`/groups/${groupId}/keys`, keyData).then(res => res.data.data); +}; + +/** + * 更新一个已存在的密钥 + * @param id 密钥ID + * @param keyData 要更新的数据 + */ +export const updateKey = (id: string, keyData: Partial>): Promise => { + return apiClient.put(`/keys/${id}`, keyData).then(res => res.data.data); +}; + +/** + * 删除一个密钥 + * @param id 密钥ID + */ +export const deleteKey = (id: string): Promise => { + return apiClient.delete(`/keys/${id}`).then(res => res.data); +}; \ No newline at end of file diff --git a/web/src/api/logs.ts b/web/src/api/logs.ts new file mode 100644 index 0000000..0a348ef --- /dev/null +++ b/web/src/api/logs.ts @@ -0,0 +1,24 @@ +import request from './index'; +import type { RequestLog } from '@/types/models'; + +export type { RequestLog }; + +export interface LogQuery { + page?: number; + size?: number; + group_id?: number; + start_time?: string; + end_time?: string; + status_code?: number; +} + +export interface PaginatedLogs { + total: number; + page: number; + size: number; + data: RequestLog[]; +} + +export const getLogs = (query: LogQuery): Promise => { + return request.get('/api/logs', { params: query }); +}; \ No newline at end of file diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts new file mode 100644 index 0000000..b925b95 --- /dev/null +++ b/web/src/api/settings.ts @@ -0,0 +1,10 @@ +import request from './index'; +import type { Setting } from '@/types/models'; + +export function getSettings() { + return request.get('/api/settings'); +} + +export function updateSettings(settings: Setting[]) { + return request.put('/api/settings', settings); +} \ No newline at end of file diff --git a/web/src/assets/vue.svg b/web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/GroupConfigForm.vue b/web/src/components/GroupConfigForm.vue new file mode 100644 index 0000000..d169fbe --- /dev/null +++ b/web/src/components/GroupConfigForm.vue @@ -0,0 +1,80 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/GroupList.vue b/web/src/components/GroupList.vue new file mode 100644 index 0000000..d5698af --- /dev/null +++ b/web/src/components/GroupList.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/web/src/components/KeyTable.vue b/web/src/components/KeyTable.vue new file mode 100644 index 0000000..8e34077 --- /dev/null +++ b/web/src/components/KeyTable.vue @@ -0,0 +1,219 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/LogFilter.vue b/web/src/components/LogFilter.vue new file mode 100644 index 0000000..78daa20 --- /dev/null +++ b/web/src/components/LogFilter.vue @@ -0,0 +1,66 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/StatsChart.vue b/web/src/components/StatsChart.vue new file mode 100644 index 0000000..3a5e335 --- /dev/null +++ b/web/src/components/StatsChart.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue new file mode 100644 index 0000000..93adabf --- /dev/null +++ b/web/src/layouts/MainLayout.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..538c4b3 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus) + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..6985c4c --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,39 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import Dashboard from '../views/Dashboard.vue'; + +const routes = [ + { + path: '/', + redirect: '/dashboard', + }, + { + path: '/dashboard', + name: 'Dashboard', + component: Dashboard, + }, + { + path: '/groups', + name: 'Groups', + // route level code-splitting + // this generates a separate chunk (About.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => import('../views/Groups.vue'), + }, + { + path: '/logs', + name: 'Logs', + component: () => import('../views/Logs.vue'), + }, + { + path: '/settings', + name: 'Settings', + component: () => import('../views/Settings.vue'), + }, +]; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes, +}); + +export default router; \ No newline at end of file diff --git a/web/src/stores/dashboardStore.ts b/web/src/stores/dashboardStore.ts new file mode 100644 index 0000000..d05cf7d --- /dev/null +++ b/web/src/stores/dashboardStore.ts @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { getDashboardStats } from '@/api/dashboard'; +import type { DashboardStats } from '@/types/models'; + +export const useDashboardStore = defineStore('dashboard', () => { + const stats = ref(null); + const loading = ref(false); + + const fetchStats = async () => { + loading.value = true; + try { + const response = await getDashboardStats(); + stats.value = response; + } catch (error) { + console.error('Failed to fetch dashboard stats:', error); + } finally { + loading.value = false; + } + }; + + return { + stats, + loading, + fetchStats, + }; +}); \ No newline at end of file diff --git a/web/src/stores/groupStore.ts b/web/src/stores/groupStore.ts new file mode 100644 index 0000000..bd6c1dd --- /dev/null +++ b/web/src/stores/groupStore.ts @@ -0,0 +1,52 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; +import * as groupApi from '@/api/groups'; +import type { Group } from '@/types/models'; + +export const useGroupStore = defineStore('group', () => { + // State + const groups = ref([]); + const selectedGroupId = ref(null); + const isLoading = ref(false); + + // Getters + const selectedGroupDetails = computed(() => { + if (!selectedGroupId.value) { + return null; + } + return groups.value.find(g => g.id === selectedGroupId.value) || null; + }); + + // Actions + async function fetchGroups() { + isLoading.value = true; + try { + groups.value = await groupApi.fetchGroups(); + // 默认选中第一个分组 + if (groups.value.length > 0 && !selectedGroupId.value) { + selectedGroupId.value = groups.value[0].id; + } + } catch (error) { + console.error('Failed to fetch groups:', error); + // 这里可以添加更复杂的错误处理逻辑,例如用户通知 + } finally { + isLoading.value = false; + } + } + + function selectGroup(id: string | null) { + selectedGroupId.value = id; + } + + return { + // State + groups, + selectedGroupId, + isLoading, + // Getters + selectedGroupDetails, + // Actions + fetchGroups, + selectGroup, + }; +}); \ No newline at end of file diff --git a/web/src/stores/keyStore.ts b/web/src/stores/keyStore.ts new file mode 100644 index 0000000..14e038a --- /dev/null +++ b/web/src/stores/keyStore.ts @@ -0,0 +1,46 @@ +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; +import * as keyApi from '@/api/keys'; +import type { Key } from '@/types/models'; +import { useGroupStore } from './groupStore'; + +export const useKeyStore = defineStore('key', () => { + // State + const keys = ref([]); + const isLoading = ref(false); + const groupStore = useGroupStore(); + + // Actions + async function fetchKeys(groupId: string) { + if (!groupId) { + keys.value = []; + return; + } + isLoading.value = true; + try { + keys.value = await keyApi.fetchKeysInGroup(groupId); + } catch (error) { + console.error(`Failed to fetch keys for group ${groupId}:`, error); + keys.value = []; // 出错时清空列表 + } finally { + isLoading.value = false; + } + } + + // Watch for changes in the selected group and fetch keys accordingly + watch(() => groupStore.selectedGroupId, (newGroupId) => { + if (newGroupId) { + fetchKeys(newGroupId); + } else { + keys.value = []; + } + }, { immediate: true }); // immediate: true ensures it runs on initialization + + return { + // State + keys, + isLoading, + // Actions + fetchKeys, + }; +}); \ No newline at end of file diff --git a/web/src/stores/logStore.ts b/web/src/stores/logStore.ts new file mode 100644 index 0000000..fb3a333 --- /dev/null +++ b/web/src/stores/logStore.ts @@ -0,0 +1,61 @@ +import { defineStore } from 'pinia'; +import { ref, reactive } from 'vue'; +import { getLogs } from '@/api/logs'; +import type { RequestLog, LogQuery, PaginatedLogs } from '@/api/logs'; + +export const useLogStore = defineStore('logs', () => { + const logs = ref([]); + const loading = ref(false); + const pagination = reactive({ + page: 1, + size: 10, + total: 0, + }); + const filters = reactive({}); + + const fetchLogs = async () => { + loading.value = true; + try { + const query: LogQuery = { + ...filters, + page: pagination.page, + size: pagination.size, + }; + const response: PaginatedLogs = await getLogs(query); + logs.value = response.data; + pagination.total = response.total; + } catch (error) { + console.error('Failed to fetch logs:', error); + } finally { + loading.value = false; + } + }; + + const setFilters = (newFilters: LogQuery) => { + Object.assign(filters, newFilters); + pagination.page = 1; + fetchLogs(); + }; + + const setPage = (page: number) => { + pagination.page = page; + fetchLogs(); + }; + + const setSize = (size: number) => { + pagination.size = size; + pagination.page = 1; + fetchLogs(); + }; + + return { + logs, + loading, + pagination, + filters, + fetchLogs, + setFilters, + setPage, + setSize, + }; +}); \ No newline at end of file diff --git a/web/src/stores/settingStore.ts b/web/src/stores/settingStore.ts new file mode 100644 index 0000000..8a45169 --- /dev/null +++ b/web/src/stores/settingStore.ts @@ -0,0 +1,47 @@ +import { defineStore } from 'pinia'; +import { getSettings, updateSettings as apiUpdateSettings } from '@/api/settings'; +import type { Setting } from '@/types/models'; +import { ElMessage } from 'element-plus'; + +interface SettingState { + settings: Setting[]; + loading: boolean; + error: any; +} + +export const useSettingStore = defineStore('setting', { + state: (): SettingState => ({ + settings: [], + loading: false, + error: null, + }), + actions: { + async fetchSettings() { + this.loading = true; + this.error = null; + try { + const response = await getSettings(); + this.settings = response.data; + } catch (error) { + this.error = error; + ElMessage.error('Failed to fetch settings.'); + } finally { + this.loading = false; + } + }, + async updateSettings(settingsToUpdate: Setting[]) { + this.loading = true; + this.error = null; + try { + await apiUpdateSettings(settingsToUpdate); + await this.fetchSettings(); // Refresh the settings after update + ElMessage.success('Settings updated successfully.'); + } catch (error) { + this.error = error; + ElMessage.error('Failed to update settings.'); + } finally { + this.loading = false; + } + }, + }, +}); \ No newline at end of file diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/src/types/models.ts b/web/src/types/models.ts new file mode 100644 index 0000000..6a053ce --- /dev/null +++ b/web/src/types/models.ts @@ -0,0 +1,59 @@ +// Based on internal/models/types.go + +export interface Key { + id: string; + group_id: string; + api_key: string; + platform: 'OpenAI' | 'Gemini'; + model_types: string[]; + rate_limit: number; + rate_limit_unit: 'minute' | 'hour' | 'day'; + usage: number; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface Group { + id: string; + name: string; + description: string; + is_default: boolean; + created_at: string; + updated_at: string; +} + +export interface GroupWithKeys extends Group { + keys: Key[]; +} + +export interface GroupRequestStat { + group_name: string; + request_count: number; +} + +export interface DashboardStats { + total_requests: number; + success_requests: number; + success_rate: number; + group_stats: GroupRequestStat[]; +} + +export interface RequestLog { + id: string; + timestamp: string; + group_id: number; + key_id: number; + source_ip: string; + status_code: number; + request_path: string; + request_body_snippet: string; +} +export interface Setting { + key: string; + value: string; +} +export interface Setting { + key: string; + value: string; +} \ No newline at end of file diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..5bde8c0 --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,53 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Groups.vue b/web/src/views/Groups.vue new file mode 100644 index 0000000..7e46667 --- /dev/null +++ b/web/src/views/Groups.vue @@ -0,0 +1,53 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Logs.vue b/web/src/views/Logs.vue new file mode 100644 index 0000000..173d885 --- /dev/null +++ b/web/src/views/Logs.vue @@ -0,0 +1,63 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue new file mode 100644 index 0000000..f7323bd --- /dev/null +++ b/web/src/views/Settings.vue @@ -0,0 +1,44 @@ + + + \ No newline at end of file diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..6ddb888 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..c2526b3 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + build: { + outDir: 'dist' + }, + base: './' +})