Commit 17ae51c0 authored by yangjianbo's avatar yangjianbo
Browse files

merge: 合并远程分支并修复代码冲突

合并了远程分支 cb72262a 的功能更新,同时保留了 ESLint 修复:

**冲突解决详情:**

1. AccountTableFilters.vue
   -  保留 emit 模式修复(避免 vue/no-mutating-props 错误)
   -  添加第三个筛选器 type(账户类型)
   -  新增 antigravity 平台和 inactive 状态选项

2. UserBalanceModal.vue
   -  保留 console.error 错误日志
   -  添加输入验证(金额校验、余额不足检查)
   -  使用 appStore.showError 向用户显示友好错误

3. AccountsView.vue
   -  保留所有 console.error 错误日志(避免 no-empty 错误)
   -  使用新 API:clearRateLimit 和 setSchedulable

4. UsageView.vue
   -  添加 console.error 错误日志
   -  添加图表功能(模型分布、使用趋势)
   -  添加粒度选择(按天/按小时)
   -  保留 XLSX 动态导入优化

**测试结果:**
-  Go tests: PASS
-  golangci-lint: 0 issues
-  ESLint: 0 errors
-  TypeScript: PASS

🤖 Generated with [Claude Code](https://claude.com/claude-code

)
Co-Authored-By: default avatarClaude Opus 4.5 <noreply@anthropic.com>
parents 4790aced cb72262a
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestCalculateImageCost_DefaultPricing 测试无分组配置时使用默认价格
func TestCalculateImageCost_DefaultPricing(t *testing.T) {
svc := &BillingService{} // pricingService 为 nil,使用硬编码默认值
// 2K 尺寸,默认价格 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
require.InDelta(t, 0.134, cost.ActualCost, 0.0001)
// 多张图片
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 3, nil, 1.0)
require.InDelta(t, 0.402, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_GroupCustomPricing 测试分组自定义价格
func TestCalculateImageCost_GroupCustomPricing(t *testing.T) {
svc := &BillingService{}
price1K := 0.10
price2K := 0.15
price4K := 0.30
groupConfig := &ImagePriceConfig{
Price1K: &price1K,
Price2K: &price2K,
Price4K: &price4K,
}
// 1K 使用分组价格
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 2, groupConfig, 1.0)
require.InDelta(t, 0.20, cost.TotalCost, 0.0001)
// 2K 使用分组价格
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.15, cost.TotalCost, 0.0001)
// 4K 使用分组价格
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
require.InDelta(t, 0.30, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_4KDoublePrice 测试 4K 默认价格翻倍
func TestCalculateImageCost_4KDoublePrice(t *testing.T) {
svc := &BillingService{}
// 4K 尺寸,默认价格翻倍 $0.134 * 2 = $0.268
cost := svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, nil, 1.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
}
// TestCalculateImageCost_RateMultiplier 测试费率倍数
func TestCalculateImageCost_RateMultiplier(t *testing.T) {
svc := &BillingService{}
// 费率倍数 1.5x
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.5)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001) // TotalCost 不变
require.InDelta(t, 0.201, cost.ActualCost, 0.0001) // ActualCost = 0.134 * 1.5
// 费率倍数 2.0x
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 2, nil, 2.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
require.InDelta(t, 0.536, cost.ActualCost, 0.0001)
}
// TestCalculateImageCost_ZeroCount 测试 imageCount=0
func TestCalculateImageCost_ZeroCount(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 0, nil, 1.0)
require.Equal(t, 0.0, cost.TotalCost)
require.Equal(t, 0.0, cost.ActualCost)
}
// TestCalculateImageCost_NegativeCount 测试 imageCount=-1
func TestCalculateImageCost_NegativeCount(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", -1, nil, 1.0)
require.Equal(t, 0.0, cost.TotalCost)
require.Equal(t, 0.0, cost.ActualCost)
}
// TestCalculateImageCost_ZeroRateMultiplier 测试费率倍数为 0 时默认使用 1.0
func TestCalculateImageCost_ZeroRateMultiplier(t *testing.T) {
svc := &BillingService{}
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
require.InDelta(t, 0.134, cost.ActualCost, 0.0001) // 0 倍率当作 1.0 处理
}
// TestGetImageUnitPrice_GroupPriorityOverDefault 测试分组价格优先于默认价格
func TestGetImageUnitPrice_GroupPriorityOverDefault(t *testing.T) {
svc := &BillingService{}
price2K := 0.20
groupConfig := &ImagePriceConfig{
Price2K: &price2K,
}
// 分组配置了 2K 价格,应该使用分组价格而不是默认的 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.20, cost.TotalCost, 0.0001)
}
// TestGetImageUnitPrice_PartialGroupConfig 测试分组部分配置时回退默认
func TestGetImageUnitPrice_PartialGroupConfig(t *testing.T) {
svc := &BillingService{}
// 只配置 1K 价格
price1K := 0.10
groupConfig := &ImagePriceConfig{
Price1K: &price1K,
}
// 1K 使用分组价格
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, groupConfig, 1.0)
require.InDelta(t, 0.10, cost.TotalCost, 0.0001)
// 2K 回退默认价格 $0.134
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, groupConfig, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
// 4K 回退默认价格 $0.268 (翻倍)
cost = svc.CalculateImageCost("gemini-3-pro-image", "4K", 1, groupConfig, 1.0)
require.InDelta(t, 0.268, cost.TotalCost, 0.0001)
}
// TestGetDefaultImagePrice_FallbackHardcoded 测试 PricingService 无数据时使用硬编码默认值
func TestGetDefaultImagePrice_FallbackHardcoded(t *testing.T) {
svc := &BillingService{} // pricingService 为 nil
// 1K 和 2K 使用相同的默认价格 $0.134
cost := svc.CalculateImageCost("gemini-3-pro-image", "1K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
cost = svc.CalculateImageCost("gemini-3-pro-image", "2K", 1, nil, 1.0)
require.InDelta(t, 0.134, cost.TotalCost, 0.0001)
}
...@@ -104,6 +104,10 @@ type ForwardResult struct { ...@@ -104,6 +104,10 @@ type ForwardResult struct {
Stream bool Stream bool
Duration time.Duration Duration time.Duration
FirstTokenMs *int // 首字时间(流式请求) FirstTokenMs *int // 首字时间(流式请求)
// 图片生成计费字段(仅 gemini-3-pro-image 使用)
ImageCount int // 生成的图片数量
ImageSize string // 图片尺寸 "1K", "2K", "4K"
} }
// UpstreamFailoverError indicates an upstream error that should trigger account failover. // UpstreamFailoverError indicates an upstream error that should trigger account failover.
...@@ -2009,25 +2013,40 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu ...@@ -2009,25 +2013,40 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
account := input.Account account := input.Account
subscription := input.Subscription subscription := input.Subscription
// 计算费用
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
}
// 获取费率倍数 // 获取费率倍数
multiplier := s.cfg.Default.RateMultiplier multiplier := s.cfg.Default.RateMultiplier
if apiKey.GroupID != nil && apiKey.Group != nil { if apiKey.GroupID != nil && apiKey.Group != nil {
multiplier = apiKey.Group.RateMultiplier multiplier = apiKey.Group.RateMultiplier
} }
cost, err := s.billingService.CalculateCost(result.Model, tokens, multiplier) var cost *CostBreakdown
if err != nil {
log.Printf("Calculate cost failed: %v", err) // 根据请求类型选择计费方式
// 使用默认费用继续 if result.ImageCount > 0 {
cost = &CostBreakdown{ActualCost: 0} // 图片生成计费
var groupConfig *ImagePriceConfig
if apiKey.Group != nil {
groupConfig = &ImagePriceConfig{
Price1K: apiKey.Group.ImagePrice1K,
Price2K: apiKey.Group.ImagePrice2K,
Price4K: apiKey.Group.ImagePrice4K,
}
}
cost = s.billingService.CalculateImageCost(result.Model, result.ImageSize, result.ImageCount, groupConfig, multiplier)
} else {
// Token 计费
tokens := UsageTokens{
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
}
var err error
cost, err = s.billingService.CalculateCost(result.Model, tokens, multiplier)
if err != nil {
log.Printf("Calculate cost failed: %v", err)
cost = &CostBreakdown{ActualCost: 0}
}
} }
// 判断计费方式:订阅模式 vs 余额模式 // 判断计费方式:订阅模式 vs 余额模式
...@@ -2039,6 +2058,10 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu ...@@ -2039,6 +2058,10 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
// 创建使用日志 // 创建使用日志
durationMs := int(result.Duration.Milliseconds()) durationMs := int(result.Duration.Milliseconds())
var imageSize *string
if result.ImageSize != "" {
imageSize = &result.ImageSize
}
usageLog := &UsageLog{ usageLog := &UsageLog{
UserID: user.ID, UserID: user.ID,
APIKeyID: apiKey.ID, APIKeyID: apiKey.ID,
...@@ -2060,6 +2083,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu ...@@ -2060,6 +2083,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
Stream: result.Stream, Stream: result.Stream,
DurationMs: &durationMs, DurationMs: &durationMs,
FirstTokenMs: result.FirstTokenMs, FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount,
ImageSize: imageSize,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
......
...@@ -17,6 +17,11 @@ type Group struct { ...@@ -17,6 +17,11 @@ type Group struct {
MonthlyLimitUSD *float64 MonthlyLimitUSD *float64
DefaultValidityDays int DefaultValidityDays int
// 图片生成计费配置(antigravity 和 gemini 平台使用)
ImagePrice1K *float64
ImagePrice2K *float64
ImagePrice4K *float64
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
...@@ -47,3 +52,19 @@ func (g *Group) HasWeeklyLimit() bool { ...@@ -47,3 +52,19 @@ func (g *Group) HasWeeklyLimit() bool {
func (g *Group) HasMonthlyLimit() bool { func (g *Group) HasMonthlyLimit() bool {
return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0 return g.MonthlyLimitUSD != nil && *g.MonthlyLimitUSD > 0
} }
// GetImagePrice 根据 image_size 返回对应的图片生成价格
// 如果分组未配置价格,返回 nil(调用方应使用默认值)
func (g *Group) GetImagePrice(imageSize string) *float64 {
switch imageSize {
case "1K":
return g.ImagePrice1K
case "2K":
return g.ImagePrice2K
case "4K":
return g.ImagePrice4K
default:
// 未知尺寸默认按 2K 计费
return g.ImagePrice2K
}
}
//go:build unit
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestGroup_GetImagePrice_1K 测试 1K 尺寸返回正确价格
func TestGroup_GetImagePrice_1K(t *testing.T) {
price := 0.10
group := &Group{
ImagePrice1K: &price,
}
result := group.GetImagePrice("1K")
require.NotNil(t, result)
require.InDelta(t, 0.10, *result, 0.0001)
}
// TestGroup_GetImagePrice_2K 测试 2K 尺寸返回正确价格
func TestGroup_GetImagePrice_2K(t *testing.T) {
price := 0.15
group := &Group{
ImagePrice2K: &price,
}
result := group.GetImagePrice("2K")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
}
// TestGroup_GetImagePrice_4K 测试 4K 尺寸返回正确价格
func TestGroup_GetImagePrice_4K(t *testing.T) {
price := 0.30
group := &Group{
ImagePrice4K: &price,
}
result := group.GetImagePrice("4K")
require.NotNil(t, result)
require.InDelta(t, 0.30, *result, 0.0001)
}
// TestGroup_GetImagePrice_UnknownSize 测试未知尺寸回退 2K
func TestGroup_GetImagePrice_UnknownSize(t *testing.T) {
price2K := 0.15
group := &Group{
ImagePrice2K: &price2K,
}
// 未知尺寸 "3K" 应该回退到 2K
result := group.GetImagePrice("3K")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
// 空字符串也回退到 2K
result = group.GetImagePrice("")
require.NotNil(t, result)
require.InDelta(t, 0.15, *result, 0.0001)
}
// TestGroup_GetImagePrice_NilValues 测试未配置时返回 nil
func TestGroup_GetImagePrice_NilValues(t *testing.T) {
group := &Group{
// 所有 ImagePrice 字段都是 nil
}
require.Nil(t, group.GetImagePrice("1K"))
require.Nil(t, group.GetImagePrice("2K"))
require.Nil(t, group.GetImagePrice("4K"))
require.Nil(t, group.GetImagePrice("unknown"))
}
// TestGroup_GetImagePrice_PartialConfig 测试部分配置
func TestGroup_GetImagePrice_PartialConfig(t *testing.T) {
price1K := 0.10
group := &Group{
ImagePrice1K: &price1K,
// ImagePrice2K 和 ImagePrice4K 未配置
}
result := group.GetImagePrice("1K")
require.NotNil(t, result)
require.InDelta(t, 0.10, *result, 0.0001)
// 2K 和 4K 返回 nil
require.Nil(t, group.GetImagePrice("2K"))
require.Nil(t, group.GetImagePrice("4K"))
}
...@@ -34,6 +34,7 @@ type LiteLLMModelPricing struct { ...@@ -34,6 +34,7 @@ type LiteLLMModelPricing struct {
LiteLLMProvider string `json:"litellm_provider"` LiteLLMProvider string `json:"litellm_provider"`
Mode string `json:"mode"` Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"` SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
} }
// PricingRemoteClient 远程价格数据获取接口 // PricingRemoteClient 远程价格数据获取接口
...@@ -51,6 +52,7 @@ type LiteLLMRawEntry struct { ...@@ -51,6 +52,7 @@ type LiteLLMRawEntry struct {
LiteLLMProvider string `json:"litellm_provider"` LiteLLMProvider string `json:"litellm_provider"`
Mode string `json:"mode"` Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"` SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage *float64 `json:"output_cost_per_image"`
} }
// PricingService 动态价格服务 // PricingService 动态价格服务
...@@ -319,6 +321,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel ...@@ -319,6 +321,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
if entry.CacheReadInputTokenCost != nil { if entry.CacheReadInputTokenCost != nil {
pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost pricing.CacheReadInputTokenCost = *entry.CacheReadInputTokenCost
} }
if entry.OutputCostPerImage != nil {
pricing.OutputCostPerImage = *entry.OutputCostPerImage
}
result[modelName] = pricing result[modelName] = pricing
} }
......
...@@ -39,6 +39,10 @@ type UsageLog struct { ...@@ -39,6 +39,10 @@ type UsageLog struct {
DurationMs *int DurationMs *int
FirstTokenMs *int FirstTokenMs *int
// 图片生成字段
ImageCount int
ImageSize *string
CreatedAt time.Time CreatedAt time.Time
User *User User *User
......
...@@ -9,7 +9,6 @@ import ( ...@@ -9,7 +9,6 @@ import (
"log" "log"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/repository" "github.com/Wei-Shaw/sub2api/internal/repository"
...@@ -22,10 +21,44 @@ import ( ...@@ -22,10 +21,44 @@ import (
// Config paths // Config paths
const ( const (
ConfigFile = "config.yaml" ConfigFileName = "config.yaml"
EnvFile = ".env" InstallLockFile = ".installed"
) )
// GetDataDir returns the data directory for storing config and lock files.
// Priority: DATA_DIR env > /app/data (if exists and writable) > current directory
func GetDataDir() string {
// Check DATA_DIR environment variable first
if dir := os.Getenv("DATA_DIR"); dir != "" {
return dir
}
// Check if /app/data exists and is writable (Docker environment)
dockerDataDir := "/app/data"
if info, err := os.Stat(dockerDataDir); err == nil && info.IsDir() {
// Try to check if writable by creating a temp file
testFile := dockerDataDir + "/.write_test"
if f, err := os.Create(testFile); err == nil {
_ = f.Close()
_ = os.Remove(testFile)
return dockerDataDir
}
}
// Default to current directory
return "."
}
// GetConfigFilePath returns the full path to config.yaml
func GetConfigFilePath() string {
return GetDataDir() + "/" + ConfigFileName
}
// GetInstallLockPath returns the full path to .installed lock file
func GetInstallLockPath() string {
return GetDataDir() + "/" + InstallLockFile
}
// SetupConfig holds the setup configuration // SetupConfig holds the setup configuration
type SetupConfig struct { type SetupConfig struct {
Database DatabaseConfig `json:"database" yaml:"database"` Database DatabaseConfig `json:"database" yaml:"database"`
...@@ -72,13 +105,12 @@ type JWTConfig struct { ...@@ -72,13 +105,12 @@ type JWTConfig struct {
// Uses multiple checks to prevent attackers from forcing re-setup by deleting config // Uses multiple checks to prevent attackers from forcing re-setup by deleting config
func NeedsSetup() bool { func NeedsSetup() bool {
// Check 1: Config file must not exist // Check 1: Config file must not exist
if _, err := os.Stat(ConfigFile); !os.IsNotExist(err) { if _, err := os.Stat(GetConfigFilePath()); !os.IsNotExist(err) {
return false // Config exists, no setup needed return false // Config exists, no setup needed
} }
// Check 2: Installation lock file (harder to bypass) // Check 2: Installation lock file (harder to bypass)
lockFile := ".installed" if _, err := os.Stat(GetInstallLockPath()); !os.IsNotExist(err) {
if _, err := os.Stat(lockFile); !os.IsNotExist(err) {
return false // Lock file exists, already installed return false // Lock file exists, already installed
} }
...@@ -197,17 +229,12 @@ func Install(cfg *SetupConfig) error { ...@@ -197,17 +229,12 @@ func Install(cfg *SetupConfig) error {
// Generate JWT secret if not provided // Generate JWT secret if not provided
if cfg.JWT.Secret == "" { if cfg.JWT.Secret == "" {
if strings.EqualFold(cfg.Server.Mode, "release") {
return fmt.Errorf("jwt secret is required in release mode")
}
secret, err := generateSecret(32) secret, err := generateSecret(32)
if err != nil { if err != nil {
return fmt.Errorf("failed to generate jwt secret: %w", err) return fmt.Errorf("failed to generate jwt secret: %w", err)
} }
cfg.JWT.Secret = secret cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
} else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 {
return fmt.Errorf("jwt secret must be at least 32 characters in release mode")
} }
// Test connections // Test connections
...@@ -244,9 +271,8 @@ func Install(cfg *SetupConfig) error { ...@@ -244,9 +271,8 @@ func Install(cfg *SetupConfig) error {
// createInstallLock creates a lock file to prevent re-installation attacks // createInstallLock creates a lock file to prevent re-installation attacks
func createInstallLock() error { func createInstallLock() error {
lockFile := ".installed"
content := fmt.Sprintf("installed_at=%s\n", time.Now().UTC().Format(time.RFC3339)) content := fmt.Sprintf("installed_at=%s\n", time.Now().UTC().Format(time.RFC3339))
return os.WriteFile(lockFile, []byte(content), 0400) // Read-only for owner return os.WriteFile(GetInstallLockPath(), []byte(content), 0400) // Read-only for owner
} }
func initializeDatabase(cfg *SetupConfig) error { func initializeDatabase(cfg *SetupConfig) error {
...@@ -397,7 +423,7 @@ func writeConfigFile(cfg *SetupConfig) error { ...@@ -397,7 +423,7 @@ func writeConfigFile(cfg *SetupConfig) error {
return err return err
} }
return os.WriteFile(ConfigFile, data, 0600) return os.WriteFile(GetConfigFilePath(), data, 0600)
} }
func generateSecret(length int) (string, error) { func generateSecret(length int) (string, error) {
...@@ -440,6 +466,7 @@ func getEnvIntOrDefault(key string, defaultValue int) int { ...@@ -440,6 +466,7 @@ func getEnvIntOrDefault(key string, defaultValue int) int {
// This is designed for Docker deployment where all config is passed via env vars // This is designed for Docker deployment where all config is passed via env vars
func AutoSetupFromEnv() error { func AutoSetupFromEnv() error {
log.Println("Auto setup enabled, configuring from environment variables...") log.Println("Auto setup enabled, configuring from environment variables...")
log.Printf("Data directory: %s", GetDataDir())
// Get timezone from TZ or TIMEZONE env var (TZ is standard for Docker) // Get timezone from TZ or TIMEZONE env var (TZ is standard for Docker)
tz := getEnvOrDefault("TZ", "") tz := getEnvOrDefault("TZ", "")
...@@ -481,17 +508,12 @@ func AutoSetupFromEnv() error { ...@@ -481,17 +508,12 @@ func AutoSetupFromEnv() error {
// Generate JWT secret if not provided // Generate JWT secret if not provided
if cfg.JWT.Secret == "" { if cfg.JWT.Secret == "" {
if strings.EqualFold(cfg.Server.Mode, "release") {
return fmt.Errorf("jwt secret is required in release mode")
}
secret, err := generateSecret(32) secret, err := generateSecret(32)
if err != nil { if err != nil {
return fmt.Errorf("failed to generate jwt secret: %w", err) return fmt.Errorf("failed to generate jwt secret: %w", err)
} }
cfg.JWT.Secret = secret cfg.JWT.Secret = secret
log.Println("Warning: JWT secret auto-generated for non-release mode. Do not use in production.") log.Println("Warning: JWT secret auto-generated. Consider setting a fixed secret for production.")
} else if strings.EqualFold(cfg.Server.Mode, "release") && len(cfg.JWT.Secret) < 32 {
return fmt.Errorf("jwt secret must be at least 32 characters in release mode")
} }
// Generate admin password if not provided // Generate admin password if not provided
......
-- 为 Antigravity 分组添加图片生成计费配置
-- 支持 gemini-3-pro-image 模型的 1K/2K/4K 分辨率按次计费
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_1k DECIMAL(20,8);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_2k DECIMAL(20,8);
ALTER TABLE groups ADD COLUMN IF NOT EXISTS image_price_4k DECIMAL(20,8);
COMMENT ON COLUMN groups.image_price_1k IS '1K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';
COMMENT ON COLUMN groups.image_price_2k IS '2K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';
COMMENT ON COLUMN groups.image_price_4k IS '4K 分辨率图片生成单价 (USD),仅 antigravity 平台使用';
-- 为使用日志添加图片生成统计字段
-- 用于记录 gemini-3-pro-image 等图片生成模型的使用情况
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_count INT DEFAULT 0;
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_size VARCHAR(10);
...@@ -54,7 +54,10 @@ ADMIN_PASSWORD= ...@@ -54,7 +54,10 @@ ADMIN_PASSWORD=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# JWT Configuration # 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_SECRET=
JWT_EXPIRE_HOUR=24 JWT_EXPIRE_HOUR=24
......
...@@ -97,7 +97,7 @@ security: ...@@ -97,7 +97,7 @@ security:
enabled: true enabled: true
# Default CSP policy (override if you host assets on other domains) # Default CSP policy (override if you host assets on other domains)
# 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖) # 默认 CSP 策略(如果静态资源托管在其他域名,请自行覆盖)
policy: "default-src 'self'; script-src 'self'; 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-ancestors 'none'; base-uri 'self'; form-action 'self'" 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: proxy_probe:
# Allow skipping TLS verification for proxy probe (debug only) # Allow skipping TLS verification for proxy probe (debug only)
# 允许代理探测时跳过 TLS 证书验证(仅用于调试) # 允许代理探测时跳过 TLS 证书验证(仅用于调试)
......
...@@ -72,7 +72,10 @@ services: ...@@ -72,7 +72,10 @@ services:
# ======================================================================= # =======================================================================
# JWT Configuration # 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_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24} - JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
......
...@@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({ ...@@ -21,6 +21,15 @@ export const apiClient: AxiosInstance = axios.create({
// ==================== Request Interceptor ==================== // ==================== Request Interceptor ====================
// Get user's timezone
const getUserTimezone = (): string => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone
} catch {
return 'UTC'
}
}
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
// Attach token from localStorage // Attach token from localStorage
...@@ -34,6 +43,14 @@ apiClient.interceptors.request.use( ...@@ -34,6 +43,14 @@ apiClient.interceptors.request.use(
config.headers['Accept-Language'] = getLocale() config.headers['Accept-Language'] = getLocale()
} }
// Attach timezone for all GET requests (backend may use it for default date ranges)
if (config.method === 'get') {
if (!config.params) {
config.params = {}
}
config.params.timezone = getUserTimezone()
}
return config return config
}, },
(error) => { (error) => {
......
...@@ -15,14 +15,7 @@ ...@@ -15,14 +15,7 @@
<div <div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600" 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"> <Icon name="chartBar" size="md" class="text-white" :stroke-width="2" />
<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>
</div> </div>
<div> <div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div> <div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
...@@ -97,19 +90,7 @@ ...@@ -97,19 +90,7 @@
t('admin.accounts.stats.totalRequests') t('admin.accounts.stats.totalRequests')
}}</span> }}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30"> <div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<svg <Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
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>
</div> </div>
</div> </div>
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
...@@ -129,19 +110,12 @@ ...@@ -129,19 +110,12 @@
t('admin.accounts.stats.avgDailyCost') t('admin.accounts.stats.avgDailyCost')
}}</span> }}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30"> <div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<svg <Icon
class="h-4 w-4 text-amber-600 dark:text-amber-400" name="calculator"
fill="none" size="sm"
viewBox="0 0 24 24" class="text-amber-600 dark:text-amber-400"
stroke="currentColor" :stroke-width="2"
> />
<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"
/>
</svg>
</div> </div>
</div> </div>
<p class="text-2xl font-bold text-gray-900 dark:text-white"> <p class="text-2xl font-bold text-gray-900 dark:text-white">
...@@ -245,19 +219,12 @@ ...@@ -245,19 +219,12 @@
<div class="card p-4"> <div class="card p-4">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30"> <div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<svg <Icon
class="h-4 w-4 text-orange-600 dark:text-orange-400" name="fire"
fill="none" size="sm"
viewBox="0 0 24 24" class="text-orange-600 dark:text-orange-400"
stroke="currentColor" :stroke-width="2"
> />
<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"
/>
</svg>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay') t('admin.accounts.stats.highestCostDay')
...@@ -295,19 +262,12 @@ ...@@ -295,19 +262,12 @@
<div class="card p-4"> <div class="card p-4">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30"> <div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<svg <Icon
class="h-4 w-4 text-indigo-600 dark:text-indigo-400" name="trendingUp"
fill="none" size="sm"
viewBox="0 0 24 24" class="text-indigo-600 dark:text-indigo-400"
stroke="currentColor" :stroke-width="2"
> />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay') t('admin.accounts.stats.highestRequestDay')
...@@ -348,19 +308,7 @@ ...@@ -348,19 +308,7 @@
<div class="card p-4"> <div class="card p-4">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30"> <div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<svg <Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" :stroke-width="2" />
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>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens') t('admin.accounts.stats.accumulatedTokens')
...@@ -390,19 +338,7 @@ ...@@ -390,19 +338,7 @@
<div class="card p-4"> <div class="card p-4">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30"> <div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<svg <Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
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>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance') t('admin.accounts.stats.performance')
...@@ -432,19 +368,12 @@ ...@@ -432,19 +368,12 @@
<div class="card p-4"> <div class="card p-4">
<div class="mb-3 flex items-center gap-2"> <div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30"> <div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<svg <Icon
class="h-4 w-4 text-lime-600 dark:text-lime-400" name="clipboard"
fill="none" size="sm"
viewBox="0 0 24 24" class="text-lime-600 dark:text-lime-400"
stroke="currentColor" :stroke-width="2"
> />
<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"
/>
</svg>
</div> </div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{ <span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity') t('admin.accounts.stats.recentActivity')
...@@ -504,14 +433,7 @@ ...@@ -504,14 +433,7 @@
v-else-if="!loading" v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400" 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"> <Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" :stroke-width="1.5" />
<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>
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p> <p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div> </div>
</div> </div>
...@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs' ...@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue' import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue' import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types' import type { Account, AccountUsageStatsResponse } from '@/types'
......
...@@ -48,13 +48,7 @@ ...@@ -48,13 +48,7 @@
<span <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" 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"> <Icon name="exclamationTriangle" size="xs" :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>
429 429
</span> </span>
<!-- Tooltip --> <!-- Tooltip -->
...@@ -73,13 +67,7 @@ ...@@ -73,13 +67,7 @@
<span <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" 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"> <Icon name="exclamationTriangle" size="xs" :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>
529 529
</span> </span>
<!-- Tooltip --> <!-- Tooltip -->
...@@ -100,6 +88,7 @@ import { computed } from 'vue' ...@@ -100,6 +88,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Account } from '@/types' import type { Account } from '@/types'
import { formatTime } from '@/utils/format' import { formatTime } from '@/utils/format'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n() const { t } = useI18n()
...@@ -179,4 +168,4 @@ const handleTempUnschedClick = () => { ...@@ -179,4 +168,4 @@ const handleTempUnschedClick = () => {
emit('show-temp-unsched', props.account) emit('show-temp-unsched', props.account)
} }
</script> </script>
\ No newline at end of file
...@@ -15,14 +15,7 @@ ...@@ -15,14 +15,7 @@
<div <div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600" 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"> <Icon name="userCircle" size="md" class="text-white" :stroke-width="2" />
<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>
</div> </div>
<div> <div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div> <div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
...@@ -70,14 +63,7 @@ ...@@ -70,14 +63,7 @@
> >
<!-- Status Line --> <!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500"> <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"> <Icon name="bolt" size="sm" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>{{ t('admin.accounts.readyToTest') }}</span> <span>{{ t('admin.accounts.readyToTest') }}</span>
</div> </div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400"> <div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
...@@ -128,14 +114,7 @@ ...@@ -128,14 +114,7 @@
v-else-if="status === 'error'" v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400" 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"> <Icon name="xCircle" size="sm" :stroke-width="2" />
<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>
<span>{{ errorMessage }}</span> <span>{{ errorMessage }}</span>
</div> </div>
</div> </div>
...@@ -147,14 +126,7 @@ ...@@ -147,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" 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')" :title="t('admin.accounts.copyOutput')"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="copy" size="sm" :stroke-width="2" />
<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>
</button> </button>
</div> </div>
...@@ -162,26 +134,12 @@ ...@@ -162,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 justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="cpu" size="sm" :stroke-width="2" />
<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>
{{ t('admin.accounts.testModel') }} {{ t('admin.accounts.testModel') }}
</span> </span>
</div> </div>
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="chatBubble" size="sm" :stroke-width="2" />
<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>
{{ t('admin.accounts.testPrompt') }} {{ t('admin.accounts.testPrompt') }}
</span> </span>
</div> </div>
...@@ -278,6 +236,7 @@ import { ref, watch, nextTick } from 'vue' ...@@ -278,6 +236,7 @@ import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types' import type { Account, ClaudeModel } from '@/types'
......
...@@ -318,19 +318,7 @@ ...@@ -318,19 +318,7 @@
<div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3"> <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"> <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"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
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>
{{ t('admin.accounts.customErrorCodesWarning') }} {{ t('admin.accounts.customErrorCodesWarning') }}
</p> </p>
</div> </div>
...@@ -391,14 +379,7 @@ ...@@ -391,14 +379,7 @@
class="hover:text-red-900 dark:hover:text-red-300" class="hover:text-red-900 dark:hover:text-red-300"
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</span> </span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400"> <span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
...@@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue' ...@@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import Icon from '@/components/icons/Icon.vue'
interface Props { interface Props {
show: boolean show: boolean
...@@ -849,7 +831,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => { ...@@ -849,7 +831,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
let credentialsChanged = false let credentialsChanged = false
if (enableProxy.value) { if (enableProxy.value) {
updates.proxy_id = proxyId.value // 后端期望 proxy_id: 0 表示清除代理,而不是 null
updates.proxy_id = proxyId.value === null ? 0 : proxyId.value
} }
if (enableConcurrency.value) { if (enableConcurrency.value) {
......
...@@ -81,19 +81,7 @@ ...@@ -81,19 +81,7 @@
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]" ]"
> >
<svg <Icon name="sparkles" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
Anthropic Anthropic
</button> </button>
<button <button
...@@ -156,19 +144,7 @@ ...@@ -156,19 +144,7 @@
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]" ]"
> >
<svg <Icon name="cloud" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
Antigravity Antigravity
</button> </button>
</div> </div>
...@@ -196,19 +172,7 @@ ...@@ -196,19 +172,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg <Icon name="sparkles" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
...@@ -238,19 +202,7 @@ ...@@ -238,19 +202,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg <Icon name="key" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ <span class="block text-sm font-medium text-gray-900 dark:text-white">{{
...@@ -286,19 +238,7 @@ ...@@ -286,19 +238,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg <Icon name="key" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
...@@ -324,19 +264,7 @@ ...@@ -324,19 +264,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg <Icon name="key" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
...@@ -380,19 +308,7 @@ ...@@ -380,19 +308,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg <Icon name="key" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white"> <span class="block text-sm font-medium text-gray-900 dark:text-white">
...@@ -487,9 +403,7 @@ ...@@ -487,9 +403,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <Icon name="user" size="sm" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white"> <span class="block text-sm font-medium text-gray-900 dark:text-white">
...@@ -532,9 +446,7 @@ ...@@ -532,9 +446,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]" ]"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <Icon name="cloud" size="sm" />
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div> </div>
<div class="min-w-0"> <div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white"> <span class="block text-sm font-medium text-gray-900 dark:text-white">
...@@ -710,19 +622,7 @@ ...@@ -710,19 +622,7 @@
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20" class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
> >
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white"> <div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white">
<svg <Icon name="key" size="sm" />
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div> </div>
<div> <div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span> <span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
...@@ -1012,19 +912,7 @@ ...@@ -1012,19 +912,7 @@
<div v-if="customErrorCodesEnabled" class="space-y-3"> <div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"> <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"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
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>
{{ t('admin.accounts.customErrorCodesWarning') }} {{ t('admin.accounts.customErrorCodesWarning') }}
</p> </p>
</div> </div>
...@@ -1083,14 +971,7 @@ ...@@ -1083,14 +971,7 @@
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300" class="hover:text-red-900 dark:hover:text-red-300"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="x" size="sm" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</span> </span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400"> <span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
...@@ -1158,23 +1039,11 @@ ...@@ -1158,23 +1039,11 @@
<div v-if="tempUnschedEnabled" class="space-y-3"> <div v-if="tempUnschedEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20"> <div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400"> <p class="text-xs text-blue-700 dark:text-blue-400">
<svg <Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
class="mr-1 inline h-4 w-4" {{ t('admin.accounts.tempUnschedulable.notice') }}
fill="none" </p>
viewBox="0 0 24 24" </div>
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>
{{ t('admin.accounts.tempUnschedulable.notice') }}
</p>
</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
...@@ -1205,9 +1074,7 @@ ...@@ -1205,9 +1074,7 @@
@click="moveTempUnschedRule(index, -1)" @click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200" class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="chevronUp" size="sm" :stroke-width="2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button> </button>
<button <button
type="button" type="button"
...@@ -1224,14 +1091,7 @@ ...@@ -1224,14 +1091,7 @@
@click="removeTempUnschedRule(index)" @click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600" class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="x" size="sm" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
...@@ -1734,6 +1594,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth' ...@@ -1734,6 +1594,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth' import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types' import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
......
...@@ -265,19 +265,7 @@ ...@@ -265,19 +265,7 @@
<div v-if="customErrorCodesEnabled" class="space-y-3"> <div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20"> <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"> <p class="text-xs text-amber-700 dark:text-amber-400">
<svg <Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
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>
{{ t('admin.accounts.customErrorCodesWarning') }} {{ t('admin.accounts.customErrorCodesWarning') }}
</p> </p>
</div> </div>
...@@ -336,14 +324,7 @@ ...@@ -336,14 +324,7 @@
@click="removeErrorCode(code)" @click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300" class="hover:text-red-900 dark:hover:text-red-300"
> >
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="x" size="sm" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</span> </span>
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400"> <span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
...@@ -412,19 +393,7 @@ ...@@ -412,19 +393,7 @@
<div v-if="tempUnschedEnabled" class="space-y-3"> <div v-if="tempUnschedEnabled" class="space-y-3">
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20"> <div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
<p class="text-xs text-blue-700 dark:text-blue-400"> <p class="text-xs text-blue-700 dark:text-blue-400">
<svg <Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
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>
{{ t('admin.accounts.tempUnschedulable.notice') }} {{ t('admin.accounts.tempUnschedulable.notice') }}
</p> </p>
</div> </div>
...@@ -458,9 +427,7 @@ ...@@ -458,9 +427,7 @@
@click="moveTempUnschedRule(index, -1)" @click="moveTempUnschedRule(index, -1)"
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200" class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="chevronUp" size="sm" :stroke-width="2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button> </button>
<button <button
type="button" type="button"
...@@ -477,14 +444,7 @@ ...@@ -477,14 +444,7 @@
@click="removeTempUnschedRule(index)" @click="removeTempUnschedRule(index)"
class="rounded p-1 text-red-500 transition-colors hover:text-red-600" class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
> >
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Icon name="x" size="sm" :stroke-width="2" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>
...@@ -702,6 +662,7 @@ import { adminAPI } from '@/api/admin' ...@@ -702,6 +662,7 @@ import { adminAPI } from '@/api/admin'
import type { Account, Proxy, Group } from '@/types' import type { Account, Proxy, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue' import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import ProxySelector from '@/components/common/ProxySelector.vue' import ProxySelector from '@/components/common/ProxySelector.vue'
import GroupSelector from '@/components/common/GroupSelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue'
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
...@@ -1092,6 +1053,10 @@ const handleSubmit = async () => { ...@@ -1092,6 +1053,10 @@ const handleSubmit = async () => {
submitting.value = true submitting.value = true
try { try {
const updatePayload: Record<string, unknown> = { ...form } const updatePayload: Record<string, unknown> = { ...form }
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
if (updatePayload.proxy_id === null) {
updatePayload.proxy_id = 0
}
// For apikey type, handle credentials update // For apikey type, handle credentials update
if (props.account.type === 'apikey') { if (props.account.type === 'apikey') {
......
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