Commit 195e227c authored by song's avatar song
Browse files

merge: 合并 upstream/main 并保留本地图片计费功能

parents 6fa704d6 752882a0
package responseheaders
import (
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
)
func TestFilterHeadersDisabledUsesDefaultAllowlist(t *testing.T) {
src := http.Header{}
src.Add("Content-Type", "application/json")
src.Add("X-Request-Id", "req-123")
src.Add("X-Test", "ok")
src.Add("Connection", "keep-alive")
src.Add("Content-Length", "123")
cfg := config.ResponseHeaderConfig{
Enabled: false,
ForceRemove: []string{"x-request-id"},
}
filtered := FilterHeaders(src, cfg)
if filtered.Get("Content-Type") != "application/json" {
t.Fatalf("expected Content-Type passthrough, got %q", filtered.Get("Content-Type"))
}
if filtered.Get("X-Request-Id") != "req-123" {
t.Fatalf("expected X-Request-Id allowed, got %q", filtered.Get("X-Request-Id"))
}
if filtered.Get("X-Test") != "" {
t.Fatalf("expected X-Test removed, got %q", filtered.Get("X-Test"))
}
if filtered.Get("Connection") != "" {
t.Fatalf("expected Connection to be removed, got %q", filtered.Get("Connection"))
}
if filtered.Get("Content-Length") != "" {
t.Fatalf("expected Content-Length to be removed, got %q", filtered.Get("Content-Length"))
}
}
func TestFilterHeadersEnabledUsesAllowlist(t *testing.T) {
src := http.Header{}
src.Add("Content-Type", "application/json")
src.Add("X-Extra", "ok")
src.Add("X-Remove", "nope")
src.Add("X-Blocked", "nope")
cfg := config.ResponseHeaderConfig{
Enabled: true,
AdditionalAllowed: []string{"x-extra"},
ForceRemove: []string{"x-remove"},
}
filtered := FilterHeaders(src, cfg)
if filtered.Get("Content-Type") != "application/json" {
t.Fatalf("expected Content-Type allowed, got %q", filtered.Get("Content-Type"))
}
if filtered.Get("X-Extra") != "ok" {
t.Fatalf("expected X-Extra allowed, got %q", filtered.Get("X-Extra"))
}
if filtered.Get("X-Remove") != "" {
t.Fatalf("expected X-Remove removed, got %q", filtered.Get("X-Remove"))
}
if filtered.Get("X-Blocked") != "" {
t.Fatalf("expected X-Blocked removed, got %q", filtered.Get("X-Blocked"))
}
}
package urlvalidator
import (
"context"
"errors"
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
)
type ValidationOptions struct {
AllowedHosts []string
RequireAllowlist bool
AllowPrivate bool
}
func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowInsecureHTTP || scheme != "http") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.TrimSpace(parsed.Hostname())
if host == "" {
return "", errors.New("invalid host")
}
if port := parsed.Port(); port != "" {
num, err := strconv.Atoi(port)
if err != nil || num <= 0 || num > 65535 {
return "", fmt.Errorf("invalid port: %s", port)
}
}
return trimmed, nil
}
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("url is required")
}
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
}
host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" {
return "", errors.New("invalid host")
}
if !opts.AllowPrivate && isBlockedHost(host) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
allowlist := normalizeAllowlist(opts.AllowedHosts)
if opts.RequireAllowlist && len(allowlist) == 0 {
return "", errors.New("allowlist is not configured")
}
if len(allowlist) > 0 && !isAllowedHost(host, allowlist) {
return "", fmt.Errorf("host is not allowed: %s", host)
}
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawPath = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全
// 用于防止 DNS Rebinding 攻击:在实际 HTTP 请求时调用此函数验证解析后的 IP
func ValidateResolvedIP(host string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return fmt.Errorf("dns resolution failed: %w", err)
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return fmt.Errorf("resolved ip %s is not allowed", ip.String())
}
}
return nil
}
func normalizeAllowlist(values []string) []string {
if len(values) == 0 {
return nil
}
normalized := make([]string, 0, len(values))
for _, v := range values {
entry := strings.ToLower(strings.TrimSpace(v))
if entry == "" {
continue
}
if host, _, err := net.SplitHostPort(entry); err == nil {
entry = host
}
normalized = append(normalized, entry)
}
return normalized
}
func isAllowedHost(host string, allowlist []string) bool {
for _, entry := range allowlist {
if entry == "" {
continue
}
if strings.HasPrefix(entry, "*.") {
suffix := strings.TrimPrefix(entry, "*.")
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
continue
}
if host == entry {
return true
}
}
return false
}
func isBlockedHost(host string) bool {
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return true
}
}
return false
}
package urlvalidator
import "testing"
func TestValidateURLFormat(t *testing.T) {
if _, err := ValidateURLFormat("", false); err == nil {
t.Fatalf("expected empty url to fail")
}
if _, err := ValidateURLFormat("://bad", false); err == nil {
t.Fatalf("expected invalid url to fail")
}
if _, err := ValidateURLFormat("http://example.com", false); err == nil {
t.Fatalf("expected http to fail when allow_insecure_http is false")
}
if _, err := ValidateURLFormat("https://example.com", false); err != nil {
t.Fatalf("expected https to pass, got %v", err)
}
if _, err := ValidateURLFormat("http://example.com", true); err != nil {
t.Fatalf("expected http to pass when allow_insecure_http is true, got %v", err)
}
if _, err := ValidateURLFormat("https://example.com:bad", true); err == nil {
t.Fatalf("expected invalid port to fail")
}
}
-- 028_add_account_notes.sql
-- Add optional admin notes for accounts.
ALTER TABLE accounts
ADD COLUMN IF NOT EXISTS notes TEXT;
COMMENT ON COLUMN accounts.notes IS 'Admin-only notes for account';
......@@ -54,10 +54,21 @@ ADMIN_PASSWORD=
# -----------------------------------------------------------------------------
# JWT Configuration
# -----------------------------------------------------------------------------
# Leave empty to auto-generate (recommended)
# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being
# invalidated after container restarts. If left empty, a random secret will
# be generated on each startup, causing all users to be logged out.
# Generate a secure secret: openssl rand -hex 32
JWT_SECRET=
JWT_EXPIRE_HOUR=24
# -----------------------------------------------------------------------------
# Configuration File (Optional)
# -----------------------------------------------------------------------------
# Path to custom config file (relative to docker-compose.yml directory)
# Copy config.example.yaml to config.yaml and modify as needed
# Leave unset to use default ./config.yaml
#CONFIG_FILE=./config.yaml
# -----------------------------------------------------------------------------
# Gemini OAuth (OPTIONAL, required only for Gemini OAuth accounts)
# -----------------------------------------------------------------------------
......
......@@ -25,8 +25,8 @@
timeouts {
read_body 30s
read_header 10s
write 60s
idle 120s
write 300s
idle 300s
}
}
}
......@@ -78,6 +78,9 @@ example.com {
compression off
}
# SSE/流式传输优化:禁用响应缓冲,立即刷新数据给客户端
flush_interval -1
# 故障转移
fail_duration 30s
max_fails 3
......@@ -92,6 +95,10 @@ example.com {
gzip 6
minimum_length 256
match {
# SSE 请求通常会带 Accept: text/event-stream,需排除压缩
not header Accept text/event-stream*
# 排除已知 SSE 路径(即便 Accept 缺失)
not path /v1/messages /v1/responses /responses /antigravity/v1/messages /v1beta/models/* /antigravity/v1beta/models/*
header Content-Type text/*
header Content-Type application/json*
header Content-Type application/javascript*
......@@ -179,6 +186,3 @@ example.com {
# =============================================================================
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
# =============================================================================
; http://example.com {
; redir https://{host}{uri} permanent
; }
# Sub2API Configuration File
# Sub2API 配置文件
#
# Copy this file to /etc/sub2api/config.yaml and modify as needed
# Documentation: https://github.com/Wei-Shaw/sub2api
# 复制此文件到 /etc/sub2api/config.yaml 并根据需要修改
#
# Documentation / 文档: https://github.com/Wei-Shaw/sub2api
# =============================================================================
# Server Configuration
# 服务器配置
# =============================================================================
server:
# Bind address (0.0.0.0 for all interfaces)
# 绑定地址(0.0.0.0 表示监听所有网络接口)
host: "0.0.0.0"
# Port to listen on
# 监听端口
port: 8080
# Mode: "debug" for development, "release" for production
# 运行模式:"debug" 用于开发,"release" 用于生产环境
mode: "release"
# Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies.
# 信任的代理地址(CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。
trusted_proxies: []
# =============================================================================
# Run Mode Configuration
# 运行模式配置
# =============================================================================
# Run mode: "standard" (default) or "simple" (for internal use)
# 运行模式:"standard"(默认)或 "simple"(内部使用)
# - standard: Full SaaS features with billing/balance checks
# - standard: 完整 SaaS 功能,包含计费和余额校验
# - simple: Hides SaaS features and skips billing/balance checks
# - simple: 隐藏 SaaS 功能,跳过计费和余额校验
run_mode: "standard"
# =============================================================================
# CORS Configuration
# 跨域资源共享 (CORS) 配置
# =============================================================================
cors:
# Allowed origins list. Leave empty to disable cross-origin requests.
# 允许的来源列表。留空则禁用跨域请求。
allowed_origins: []
# Allow credentials (cookies/authorization headers). Cannot be used with "*".
# 允许携带凭证(cookies/授权头)。不能与 "*" 通配符同时使用。
allow_credentials: true
# =============================================================================
# Security Configuration
# 安全配置
# =============================================================================
security:
url_allowlist:
# Enable URL allowlist validation (disable to skip all URL checks)
# 启用 URL 白名单验证(禁用则跳过所有 URL 检查)
enabled: false
# Allowed upstream hosts for API proxying
# 允许代理的上游 API 主机列表
upstream_hosts:
- "api.openai.com"
- "api.anthropic.com"
- "api.kimi.com"
- "open.bigmodel.cn"
- "api.minimaxi.com"
- "generativelanguage.googleapis.com"
- "cloudcode-pa.googleapis.com"
- "*.openai.azure.com"
# Allowed hosts for pricing data download
# 允许下载定价数据的主机列表
pricing_hosts:
- "raw.githubusercontent.com"
# Allowed hosts for CRS sync (required when using CRS sync)
# 允许 CRS 同步的主机列表(使用 CRS 同步功能时必须配置)
crs_hosts: []
# Allow localhost/private IPs for upstream/pricing/CRS (use only in trusted networks)
# 允许本地/私有 IP 地址用于上游/定价/CRS(仅在可信网络中使用)
allow_private_hosts: true
# Allow http:// URLs when allowlist is disabled (default: false, require https)
# 白名单禁用时是否允许 http:// URL(默认: false,要求 https)
allow_insecure_http: true
response_headers:
# Enable configurable response header filtering (disable to use default allowlist)
# 启用可配置的响应头过滤(禁用则使用默认白名单)
enabled: false
# Extra allowed response headers from upstream
# 额外允许的上游响应头
additional_allowed: []
# Force-remove response headers from upstream
# 强制移除的上游响应头
force_remove: []
csp:
# Enable Content-Security-Policy header
# 启用内容安全策略 (CSP) 响应头
enabled: true
# Default CSP policy (override if you host assets on other domains)
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
policy: "default-src 'self'; script-src 'self' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试)
insecure_skip_verify: false
# =============================================================================
# Gateway Configuration
# 网关配置
# =============================================================================
gateway:
# Timeout for waiting upstream response headers (seconds)
# 等待上游响应头超时时间(秒)
response_header_timeout: 300
response_header_timeout: 600
# Max request body size in bytes (default: 100MB)
# 请求体最大字节数(默认 100MB)
max_body_size: 104857600
# Connection pool isolation strategy:
# 连接池隔离策略:
# - proxy: Isolate by proxy, same proxy shares connection pool (suitable for few proxies, many accounts)
# - proxy: 按代理隔离,同一代理共享连接池(适合代理少、账户多)
# - account: Isolate by account, same account shares connection pool (suitable for few accounts, strict isolation)
# - account: 按账户隔离,同一账户共享连接池(适合账户少、需严格隔离)
# - account_proxy: Isolate by account+proxy combination (default, finest granularity)
# - account_proxy: 按账户+代理组合隔离(默认,最细粒度)
connection_pool_isolation: "account_proxy"
# HTTP 上游连接池配置(HTTP/2 + 多代理场景默认)
# HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults)
# HTTP 上游连接池配置(HTTP/2 + 多代理场景默认值)
# Max idle connections across all hosts
# 所有主机的最大空闲连接数
max_idle_conns: 240
# Max idle connections per host
# 每个主机的最大空闲连接数
max_idle_conns_per_host: 120
# Max connections per host
# 每个主机的最大连接数
max_conns_per_host: 240
idle_conn_timeout_seconds: 300
# Idle connection timeout (seconds)
# 空闲连接超时时间(秒)
idle_conn_timeout_seconds: 90
# Upstream client cache settings
# 上游连接池客户端缓存配置
# max_upstream_clients: Max cached clients, evicts least recently used when exceeded
# max_upstream_clients: 最大缓存客户端数量,超出后淘汰最久未使用的
# client_idle_ttl_seconds: 客户端空闲回收阈值(秒),超时且无活跃请求时回收
max_upstream_clients: 5000
# client_idle_ttl_seconds: Client idle reclaim threshold (seconds), reclaimed when idle and no active requests
# client_idle_ttl_seconds: 客户端空闲回收阈值(秒),超时且无活跃请求时回收
client_idle_ttl_seconds: 900
# Concurrency slot expiration time (minutes)
# 并发槽位过期时间(分钟)
concurrency_slot_ttl_minutes: 15
concurrency_slot_ttl_minutes: 30
# Stream data interval timeout (seconds), 0=disable
# 流数据间隔超时(秒),0=禁用
stream_data_interval_timeout: 180
# Stream keepalive interval (seconds), 0=disable
# 流式 keepalive 间隔(秒),0=禁用
stream_keepalive_interval: 10
# SSE max line size in bytes (default: 10MB)
# SSE 单行最大字节数(默认 10MB)
max_line_size: 10485760
# Log upstream error response body summary (safe/truncated; does not log request content)
# 记录上游错误响应体摘要(安全/截断;不记录请求内容)
log_upstream_error_body: false
# Max bytes to log from upstream error body
# 记录上游错误响应体的最大字节数
log_upstream_error_body_max_bytes: 2048
# Auto inject anthropic-beta header for API-key accounts when needed (default: off)
# 需要时自动为 API-key 账户注入 anthropic-beta 头(默认:关闭)
inject_beta_for_apikey: false
# Allow failover on selected 400 errors (default: off)
# 允许在特定 400 错误时进行故障转移(默认:关闭)
failover_on_400: false
# =============================================================================
# Concurrency Wait Configuration
# 并发等待配置
# =============================================================================
concurrency:
# SSE ping interval during concurrency wait (seconds)
# 并发等待期间的 SSE ping 间隔(秒)
ping_interval: 10
# =============================================================================
# Database Configuration (PostgreSQL)
# 数据库配置 (PostgreSQL)
# =============================================================================
database:
# Database host address
# 数据库主机地址
host: "localhost"
# Database port
# 数据库端口
port: 5432
# Database username
# 数据库用户名
user: "postgres"
# Database password
# 数据库密码
password: "your_secure_password_here"
# Database name
# 数据库名称
dbname: "sub2api"
# SSL mode: disable, require, verify-ca, verify-full
# SSL 模式:disable(禁用), require(要求), verify-ca(验证CA), verify-full(完全验证)
sslmode: "disable"
# =============================================================================
# Redis Configuration
# Redis 配置
# =============================================================================
redis:
# Redis host address
# Redis 主机地址
host: "localhost"
# Redis port
# Redis 端口
port: 6379
# Leave empty if no password is set
# Redis password (leave empty if no password is set)
# Redis 密码(如果未设置密码则留空)
password: ""
# Database number (0-15)
# 数据库编号(0-15)
db: 0
# =============================================================================
# JWT Configuration
# JWT 配置
# =============================================================================
jwt:
# IMPORTANT: Change this to a random string in production!
# Generate with: openssl rand -hex 32
# 重要:生产环境中请更改为随机字符串!
# Generate with / 生成命令: openssl rand -hex 32
secret: "change-this-to-a-secure-random-string"
# Token expiration time in hours
# Token expiration time in hours (max 24)
# 令牌过期时间(小时,最大 24)
expire_hour: 24
# =============================================================================
# Default Settings
# 默认设置
# =============================================================================
default:
# Initial admin account (created on first run)
# 初始管理员账户(首次运行时创建)
admin_email: "admin@example.com"
admin_password: "admin123"
# Default settings for new users
user_concurrency: 5 # Max concurrent requests per user
user_balance: 0 # Initial balance for new users
# 新用户默认设置
# Max concurrent requests per user
# 每用户最大并发请求数
user_concurrency: 5
# Initial balance for new users
# 新用户初始余额
user_balance: 0
# API key settings
api_key_prefix: "sk-" # Prefix for generated API keys
# API 密钥设置
# Prefix for generated API keys
# 生成的 API 密钥前缀
api_key_prefix: "sk-"
# Rate multiplier (affects billing calculation)
# 费率倍数(影响计费计算)
rate_multiplier: 1.0
# =============================================================================
# Rate Limiting
# 速率限制
# =============================================================================
rate_limit:
# Cooldown time (in minutes) when upstream returns 529 (overloaded)
# 上游返回 529(过载)时的冷却时间(分钟)
overload_cooldown_minutes: 10
# =============================================================================
# Pricing Data Source (Optional)
# 定价数据源(可选)
# =============================================================================
pricing:
# URL to fetch model pricing data (default: LiteLLM)
# 获取模型定价数据的 URL(默认:LiteLLM)
remote_url: "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
# Hash verification URL (optional)
# 哈希校验 URL(可选)
hash_url: ""
# Local data directory for caching
# 本地数据缓存目录
data_dir: "./data"
# Fallback pricing file
# 备用定价文件
fallback_file: "./resources/model-pricing/model_prices_and_context_window.json"
# Update interval in hours
# 更新间隔(小时)
update_interval_hours: 24
# Hash check interval in minutes
# 哈希检查间隔(分钟)
hash_check_interval_minutes: 10
# =============================================================================
# Gateway (Optional)
# Billing Configuration
# 计费配置
# =============================================================================
gateway:
# Wait time (in seconds) for upstream response headers (streaming body not affected)
response_header_timeout: 300
# Log upstream error response body summary (safe/truncated; does not log request content)
log_upstream_error_body: false
# Max bytes to log from upstream error body
log_upstream_error_body_max_bytes: 2048
# Auto inject anthropic-beta for API-key accounts when needed (default off)
inject_beta_for_apikey: false
# Allow failover on selected 400 errors (default off)
failover_on_400: false
billing:
circuit_breaker:
# Enable circuit breaker for billing service
# 启用计费服务熔断器
enabled: true
# Number of failures before opening circuit
# 触发熔断的失败次数阈值
failure_threshold: 5
# Time to wait before attempting reset (seconds)
# 熔断后重试等待时间(秒)
reset_timeout_seconds: 30
# Number of requests to allow in half-open state
# 半开状态允许通过的请求数
half_open_requests: 3
# =============================================================================
# Turnstile Configuration
# Turnstile 人机验证配置
# =============================================================================
turnstile:
# Require Turnstile in release mode (when enabled, login/register will fail if not configured)
# 在 release 模式下要求 Turnstile 验证(启用后,若未配置则登录/注册会失败)
required: false
# =============================================================================
# Gemini OAuth (Required for Gemini accounts)
# Gemini OAuth 配置(Gemini 账户必需)
# =============================================================================
# Sub2API supports TWO Gemini OAuth modes:
# Sub2API 支持两种 Gemini OAuth 模式:
#
# 1. Code Assist OAuth (需要 GCP project_id)
# 1. Code Assist OAuth (requires GCP project_id)
# 1. Code Assist OAuth(需要 GCP project_id)
# - Uses: cloudcode-pa.googleapis.com (Code Assist API)
# - 使用:cloudcode-pa.googleapis.com(Code Assist API)
#
# 2. AI Studio OAuth (不需要 project_id)
# 2. AI Studio OAuth (no project_id needed)
# 2. AI Studio OAuth(不需要 project_id)
# - Uses: generativelanguage.googleapis.com (AI Studio API)
# - 使用:generativelanguage.googleapis.com(AI Studio API)
#
# Default: Uses Gemini CLI's public OAuth credentials (same as Google's official CLI tool)
# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同)
gemini:
oauth:
# Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio)
# Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio)
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
client_secret: "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"
# Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
# 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。
scopes: ""
quota:
# Optional: local quota simulation for Gemini Code Assist (local billing).
# 可选:Gemini Code Assist 本地配额模拟(本地计费)。
# These values are used for UI progress + precheck scheduling, not official Google quotas.
# 这些值用于 UI 进度显示和预检调度,并非 Google 官方配额。
tiers:
LEGACY:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 50
# Flash model requests per day
# Flash 模型每日请求数
flash_rpd: 1500
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 30
PRO:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 1500
# Flash model requests per day
# Flash 模型每日请求数
flash_rpd: 4000
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 5
ULTRA:
# Pro model requests per day
# Pro 模型每日请求数
pro_rpd: 2000
# Flash model requests per day (0 = unlimited)
# Flash 模型每日请求数(0 = 无限制)
flash_rpd: 0
# Cooldown time (minutes) after hitting quota
# 达到配额后的冷却时间(分钟)
cooldown_minutes: 5
# =============================================================================
# Sub2API Docker Compose Configuration
# Sub2API Docker Compose Test Configuration (Local Build)
# =============================================================================
# Quick Start:
# 1. Copy .env.example to .env and configure
# 2. docker-compose up -d
# 3. Check logs: docker-compose logs -f sub2api
# 2. docker-compose -f docker-compose-test.yml up -d --build
# 3. Check logs: docker-compose -f docker-compose-test.yml logs -f sub2api
# 4. Access: http://localhost:8080
#
# This configuration builds the image from source (Dockerfile in project root).
# All configuration is done via environment variables.
# No Setup Wizard needed - the system auto-initializes on first run.
# =============================================================================
......@@ -17,6 +18,9 @@ services:
# ===========================================================================
sub2api:
image: sub2api:latest
build:
context: ..
dockerfile: Dockerfile
container_name: sub2api
restart: unless-stopped
ulimits:
......@@ -28,6 +32,8 @@ services:
volumes:
# Data persistence (config.yaml will be auto-generated here)
- sub2api_data:/app/data
# Mount custom config.yaml (optional, overrides auto-generated config)
- ./config.yaml:/app/data/config.yaml:ro
environment:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
......@@ -91,6 +97,12 @@ services:
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
# Allow private IP addresses for CRS sync (for internal deployments)
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-true}
depends_on:
postgres:
condition: service_healthy
......
......@@ -28,6 +28,9 @@ services:
volumes:
# Data persistence (config.yaml will be auto-generated here)
- sub2api_data:/app/data
# Optional: Mount custom config.yaml (uncomment and create the file first)
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
# - ./config.yaml:/app/data/config.yaml:ro
environment:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
......@@ -69,7 +72,10 @@ services:
# =======================================================================
# JWT Configuration
# =======================================================================
# Leave empty to auto-generate (recommended)
# IMPORTANT: Set a fixed JWT_SECRET to prevent login sessions from being
# invalidated after container restarts. If left empty, a random secret
# will be generated on each startup.
# Generate a secure secret: openssl rand -hex 32
- JWT_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
......@@ -91,6 +97,13 @@ services:
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
# =======================================================================
# Security Configuration (URL Allowlist)
# =======================================================================
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
# Allow private IP addresses for CRS sync (for internal deployments)
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
depends_on:
postgres:
condition: service_healthy
......@@ -138,7 +151,7 @@ services:
# Redis Cache
# ===========================================================================
redis:
image: redis:7-alpine
image: redis:8-alpine
container_name: sub2api-redis
restart: unless-stopped
ulimits:
......
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: "vue-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
ecmaVersion: "latest",
sourceType: "module",
extraFileExtensions: [".vue"],
},
plugins: ["vue", "@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:vue/vue3-essential",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-constant-condition": "off",
"no-mixed-spaces-and-tabs": "off",
"no-useless-escape": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
"vue/no-use-v-if-with-v-for": "off",
},
};
legacy-peer-deps=true
# 允许运行所有包的构建脚本
# esbuild 和 vue-demi 是已知安全的包,需要 postinstall 脚本才能正常工作
ignore-scripts=false
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -8,6 +8,7 @@
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
......@@ -30,6 +31,10 @@
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.16",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -26,14 +26,42 @@ export interface SystemSettings {
smtp_host: string
smtp_port: number
smtp_username: string
smtp_password: string
smtp_password_configured: boolean
smtp_from_email: string
smtp_from_name: string
smtp_use_tls: boolean
// Cloudflare Turnstile settings
turnstile_enabled: boolean
turnstile_site_key: string
turnstile_secret_key: string
turnstile_secret_key_configured: boolean
// Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean
identity_patch_prompt: string
}
export interface UpdateSettingsRequest {
registration_enabled?: boolean
email_verify_enabled?: boolean
default_balance?: number
default_concurrency?: number
site_name?: string
site_logo?: string
site_subtitle?: string
api_base_url?: string
contact_info?: string
doc_url?: string
smtp_host?: string
smtp_port?: number
smtp_username?: string
smtp_password?: string
smtp_from_email?: string
smtp_from_name?: string
smtp_use_tls?: boolean
turnstile_enabled?: boolean
turnstile_site_key?: string
turnstile_secret_key?: string
enable_identity_patch?: boolean
identity_patch_prompt?: string
}
/**
......@@ -50,7 +78,7 @@ export async function getSettings(): Promise<SystemSettings> {
* @param settings - Partial settings to update
* @returns Updated settings
*/
export async function updateSettings(settings: Partial<SystemSettings>): Promise<SystemSettings> {
export async function updateSettings(settings: UpdateSettingsRequest): Promise<SystemSettings> {
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings)
return data
}
......
......@@ -5,6 +5,7 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse } from '@/types'
import { getLocale } from '@/i18n'
// ==================== Axios Instance Configuration ====================
......@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
// Attach locale for backend translations
if (config.headers) {
config.headers['Accept-Language'] = getLocale()
}
return config
},
(error) => {
......@@ -62,8 +69,24 @@ apiClient.interceptors.response.use(
// 401: Unauthorized - clear token and redirect to login
if (status === 401) {
const hasToken = !!localStorage.getItem('auth_token')
const url = error.config?.url || ''
const isAuthEndpoint =
url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh')
const headers = error.config?.headers as Record<string, unknown> | undefined
const authHeader = headers?.Authorization ?? headers?.authorization
const sentAuth =
typeof authHeader === 'string'
? authHeader.trim() !== ''
: Array.isArray(authHeader)
? authHeader.length > 0
: !!authHeader
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_user')
if ((hasToken || sentAuth) && !isAuthEndpoint) {
sessionStorage.setItem('auth_expired', '1')
}
// Only redirect if not already on login page
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
......
......@@ -15,14 +15,7 @@
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<Icon name="chartBar" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
......@@ -97,19 +90,7 @@
t('admin.accounts.stats.totalRequests')
}}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<svg
class="h-4 w-4 text-blue-600 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
......@@ -129,19 +110,12 @@
t('admin.accounts.stats.avgDailyCost')
}}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<svg
class="h-4 w-4 text-amber-600 dark:text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
<Icon
name="calculator"
size="sm"
class="text-amber-600 dark:text-amber-400"
:stroke-width="2"
/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
......@@ -245,19 +219,12 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<svg
class="h-4 w-4 text-orange-600 dark:text-orange-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
<Icon
name="fire"
size="sm"
class="text-orange-600 dark:text-orange-400"
:stroke-width="2"
/>
</svg>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay')
......@@ -295,19 +262,12 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<svg
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
<Icon
name="trendingUp"
size="sm"
class="text-indigo-600 dark:text-indigo-400"
:stroke-width="2"
/>
</svg>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay')
......@@ -348,19 +308,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<svg
class="h-4 w-4 text-teal-600 dark:text-teal-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" :stroke-width="2" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens')
......@@ -390,19 +338,7 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<svg
class="h-4 w-4 text-rose-600 dark:text-rose-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance')
......@@ -432,19 +368,12 @@
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<svg
class="h-4 w-4 text-lime-600 dark:text-lime-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
<Icon
name="clipboard"
size="sm"
class="text-lime-600 dark:text-lime-400"
:stroke-width="2"
/>
</svg>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity')
......@@ -504,14 +433,7 @@
v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" :stroke-width="1.5" />
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div>
</div>
......@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'
......
......@@ -5,7 +5,7 @@
v-if="isTempUnschedulable"
type="button"
:class="['badge text-xs', statusClass, 'cursor-pointer']"
:title="t('admin.accounts.tempUnschedulable.viewDetails')"
:title="t('admin.accounts.status.viewTempUnschedDetails')"
@click="handleTempUnschedClick"
>
{{ statusText }}
......@@ -48,20 +48,14 @@
<span
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
429
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
......@@ -73,20 +67,14 @@
<span
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
529
</span>
<!-- Tooltip -->
<div
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
>
Overloaded until {{ formatTime(account.overload_until) }}
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
<div
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
></div>
......@@ -100,6 +88,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Account } from '@/types'
import { formatTime } from '@/utils/format'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
......@@ -160,7 +149,7 @@ const statusClass = computed(() => {
// Computed: status text
const statusText = computed(() => {
if (hasError.value) {
return t('common.error')
return t('admin.accounts.status.error')
}
if (isTempUnschedulable.value) {
return t('admin.accounts.status.tempUnschedulable')
......@@ -171,7 +160,7 @@ const statusText = computed(() => {
if (isRateLimited.value || isOverloaded.value) {
return t('admin.accounts.status.limited')
}
return t(`common.${props.account.status}`)
return t(`admin.accounts.status.${props.account.status}`)
})
const handleTempUnschedClick = () => {
......
......@@ -15,14 +15,7 @@
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="userCircle" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
......@@ -48,21 +41,18 @@
</span>
</div>
<!-- Model Selection -->
<div class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<select
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
>
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
<option v-for="model in availableModels" :key="model.id" :value="model.id">
{{ model.display_name }} ({{ model.id }})
</option>
</select>
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
......@@ -73,14 +63,7 @@
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<Icon name="bolt" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
......@@ -131,14 +114,7 @@
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="xCircle" size="sm" :stroke-width="2" />
<span>{{ errorMessage }}</span>
</div>
</div>
......@@ -150,14 +126,7 @@
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<Icon name="copy" size="sm" :stroke-width="2" />
</button>
</div>
......@@ -165,26 +134,12 @@
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
<Icon name="cpu" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<Icon name="chatBubble" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
......@@ -280,6 +235,8 @@
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
......
......@@ -318,19 +318,7 @@
<div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3">
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
<p class="text-xs text-amber-700 dark:text-amber-400">
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
{{ t('admin.accounts.customErrorCodesWarning') }}
</p>
</div>
......@@ -391,14 +379,7 @@
class="hover:text-red-900 dark:hover:text-red-300"
@click="removeErrorCode(code)"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
</button>
</span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
......@@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue'
interface Props {
show: boolean
......@@ -849,7 +831,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
let credentialsChanged = false
if (enableProxy.value) {
updates.proxy_id = proxyId.value
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
updates.proxy_id = proxyId.value === null ? 0 : proxyId.value
}
if (enableConcurrency.value) {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment