diff --git a/Dockerfile b/Dockerfile index e912036..4ca9b21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder +FROM node:22-bookworm-slim AS builder WORKDIR /build COPY web/package.json . -COPY web/bun.lock . -RUN bun install +COPY web/package-lock.json . +RUN npm ci --legacy-peer-deps COPY ./web . COPY ./VERSION . -RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=4096" VITE_REACT_APP_VERSION=$(cat VERSION) bun run build +RUN DISABLE_ESLINT_PLUGIN='true' NODE_OPTIONS="--max-old-space-size=4096" VITE_REACT_APP_VERSION=$(cat VERSION) npm run build FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 diff --git a/controller/image_studio.go b/controller/image_studio.go index 155d8d7..49c8b51 100644 --- a/controller/image_studio.go +++ b/controller/image_studio.go @@ -14,6 +14,7 @@ import ( "net/textproto" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -28,18 +29,17 @@ import ( ) const ( - imageStudioGroup = "image" - imageStudioTaskStatusPending = "pending" - imageStudioTaskStatusRunning = "running" - imageStudioTaskStatusSuccess = "success" - imageStudioTaskStatusFailure = "failure" - imageStudioTaskTTL = 30 * time.Minute - imageStudioTaskCleanupEvery = 5 * time.Minute - imageStudioHTTPTimeout = 10 * time.Minute - imageStudioGenerateAction = "generate" - imageStudioEditAction = "edit" - imageStudioResultBaseDir = "/data/image-studio-results" - imageStudioResultURLPrefix = "/image-studio-results" + imageStudioGroup = "image" + imageStudioTaskStatusPending = "pending" + imageStudioTaskStatusRunning = "running" + imageStudioTaskStatusSuccess = "success" + imageStudioTaskStatusFailure = "failure" + imageStudioTaskCleanupEvery = 5 * time.Minute + imageStudioHTTPTimeout = 10 * time.Minute + imageStudioGenerateAction = "generate" + imageStudioEditAction = "edit" + imageStudioResultBaseDir = "/data/image-studio-results" + imageStudioResultURLPrefix = "/image-studio-results" ) type imageStudioGenerateRequest struct { @@ -71,21 +71,325 @@ type imageStudioTask struct { UpdatedAt int64 `json:"updated_at"` } +type imageStudioModelSetting struct { + Model string `json:"model"` + Enabled bool `json:"enabled"` + AllowGenerate bool `json:"allow_generate"` + AllowEdit bool `json:"allow_edit"` + AllowRatio bool `json:"allow_ratio"` + AllowResolution bool `json:"allow_resolution"` + AllowQuality bool `json:"allow_quality"` + AllowedSizes []string `json:"allowed_sizes,omitempty"` + AllowedQualities []string `json:"allowed_qualities,omitempty"` + DefaultSize string `json:"default_size,omitempty"` + DefaultQuality string `json:"default_quality,omitempty"` +} + +type imageStudioSettingsPayload struct { + Enabled bool `json:"enabled"` + DefaultModel string `json:"default_model,omitempty"` + AllowOpenOriginal bool `json:"allow_open_original"` + RetentionMinutes int `json:"retention_minutes"` + AllowedModels []string `json:"allowed_models,omitempty"` + ModelSettings []imageStudioModelSetting `json:"model_settings,omitempty"` +} + +var imageStudioSizeRatios = map[string]string{ + "auto": "auto", + "1024x1024": "1:1", + "1536x1024": "3:2", + "1024x1536": "2:3", + "2048x2048": "1:1", + "2048x1152": "16:9", + "1152x2048": "9:16", + "3840x2160": "16:9", + "2160x3840": "9:16", +} + var ( imageStudioTasks = struct { sync.RWMutex items map[string]*imageStudioTask }{items: make(map[string]*imageStudioTask)} imageStudioCleanupOnce sync.Once + imageStudioSettingsCache = struct { + sync.RWMutex + raw string + settings imageStudioSettingsPayload + }{} + imageStudioTTLCache = struct { + sync.RWMutex + raw string + ttl time.Duration + }{} ) +func getImageStudioTaskTTL() time.Duration { + if raw, ok := common.OptionMap["ImageStudioRetentionMinutes"]; ok { + normalized := strings.TrimSpace(raw) + imageStudioTTLCache.RLock() + if imageStudioTTLCache.raw == normalized && imageStudioTTLCache.ttl > 0 { + ttl := imageStudioTTLCache.ttl + imageStudioTTLCache.RUnlock() + return ttl + } + imageStudioTTLCache.RUnlock() + minutes := 30 + if parsed, err := strconv.Atoi(normalized); err == nil && parsed > 0 { + minutes = parsed + } + ttl := time.Duration(minutes) * time.Minute + imageStudioTTLCache.Lock() + imageStudioTTLCache.raw = normalized + imageStudioTTLCache.ttl = ttl + imageStudioTTLCache.Unlock() + return ttl + } + return 30 * time.Minute +} + +func normalizeImageStudioSizeList(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, item := range values { + size := normalizeImageStudioSize(item) + if size == "" { + continue + } + if _, ok := seen[size]; ok { + continue + } + seen[size] = struct{}{} + result = append(result, size) + } + if len(result) == 0 { + return nil + } + return result +} + +func normalizeImageStudioQualityList(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, item := range values { + quality := normalizeImageStudioQuality(item) + if quality == "" { + continue + } + if _, ok := seen[quality]; ok { + continue + } + seen[quality] = struct{}{} + result = append(result, quality) + } + if len(result) == 0 { + return nil + } + return result +} + +func normalizeImageStudioModelSetting(item imageStudioModelSetting) imageStudioModelSetting { + item.Model = strings.TrimSpace(item.Model) + item.AllowedSizes = normalizeImageStudioSizeList(item.AllowedSizes) + item.AllowedQualities = normalizeImageStudioQualityList(item.AllowedQualities) + item.DefaultSize = normalizeImageStudioSize(item.DefaultSize) + item.DefaultQuality = normalizeImageStudioQuality(item.DefaultQuality) + if item.DefaultSize == "" { + item.DefaultSize = "auto" + } + if item.DefaultQuality == "" { + item.DefaultQuality = "auto" + } + if !item.AllowGenerate && !item.AllowEdit { + item.AllowGenerate = true + } + if len(item.AllowedSizes) == 0 { + item.AllowedSizes = []string{"auto"} + } + if len(item.AllowedQualities) == 0 { + item.AllowedQualities = []string{"auto", "low", "medium", "high"} + } + return item +} + +func getDefaultImageStudioSettings() imageStudioSettingsPayload { + return imageStudioSettingsPayload{ + Enabled: true, + DefaultModel: "gpt-image-2", + AllowOpenOriginal: true, + RetentionMinutes: 30, + } +} + +func getImageStudioSettings() imageStudioSettingsPayload { + raw, _ := common.OptionMap["ImageStudioSettings"] + raw = strings.TrimSpace(raw) + + imageStudioSettingsCache.RLock() + if imageStudioSettingsCache.raw == raw { + cached := imageStudioSettingsCache.settings + imageStudioSettingsCache.RUnlock() + return cached + } + imageStudioSettingsCache.RUnlock() + + settings := getDefaultImageStudioSettings() + if raw == "" { + imageStudioSettingsCache.Lock() + imageStudioSettingsCache.raw = raw + imageStudioSettingsCache.settings = settings + imageStudioSettingsCache.Unlock() + return settings + } + var parsed imageStudioSettingsPayload + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + imageStudioSettingsCache.Lock() + imageStudioSettingsCache.raw = raw + imageStudioSettingsCache.settings = settings + imageStudioSettingsCache.Unlock() + return settings + } + settings.Enabled = parsed.Enabled + if strings.TrimSpace(parsed.DefaultModel) != "" { + settings.DefaultModel = strings.TrimSpace(parsed.DefaultModel) + } + settings.AllowOpenOriginal = parsed.AllowOpenOriginal + if parsed.RetentionMinutes > 0 { + settings.RetentionMinutes = parsed.RetentionMinutes + } + if len(parsed.AllowedModels) > 0 { + seen := make(map[string]struct{}, len(parsed.AllowedModels)) + settings.AllowedModels = make([]string, 0, len(parsed.AllowedModels)) + for _, item := range parsed.AllowedModels { + modelName := strings.TrimSpace(item) + if modelName == "" { + continue + } + if _, ok := seen[modelName]; ok { + continue + } + seen[modelName] = struct{}{} + settings.AllowedModels = append(settings.AllowedModels, modelName) + } + } + if len(parsed.ModelSettings) > 0 { + seen := make(map[string]struct{}, len(parsed.ModelSettings)) + settings.ModelSettings = make([]imageStudioModelSetting, 0, len(parsed.ModelSettings)) + for _, item := range parsed.ModelSettings { + normalized := normalizeImageStudioModelSetting(item) + if normalized.Model == "" { + continue + } + if _, ok := seen[normalized.Model]; ok { + continue + } + seen[normalized.Model] = struct{}{} + settings.ModelSettings = append(settings.ModelSettings, normalized) + } + } + imageStudioSettingsCache.Lock() + imageStudioSettingsCache.raw = raw + imageStudioSettingsCache.settings = settings + imageStudioSettingsCache.Unlock() + return settings +} + +func getImageStudioModelSetting(modelName string) (imageStudioModelSetting, bool) { + settings := getImageStudioSettings() + for _, item := range settings.ModelSettings { + if strings.EqualFold(strings.TrimSpace(item.Model), strings.TrimSpace(modelName)) { + return item, true + } + } + return imageStudioModelSetting{}, false +} + +func shouldAllowImageStudioModel(modelName string) bool { + settings := getImageStudioSettings() + if len(settings.AllowedModels) == 0 { + return true + } + for _, item := range settings.AllowedModels { + if strings.EqualFold(strings.TrimSpace(item), strings.TrimSpace(modelName)) { + return true + } + } + return false +} + +func getImageStudioAllowedSizesForModel(modelName string) []string { + if setting, ok := getImageStudioModelSetting(modelName); ok && len(setting.AllowedSizes) > 0 { + return setting.AllowedSizes + } + return nil +} + +func getImageStudioAllowedQualitiesForModel(modelName string) []string { + if setting, ok := getImageStudioModelSetting(modelName); ok && len(setting.AllowedQualities) > 0 { + return setting.AllowedQualities + } + return nil +} + +func buildImageStudioPublicSettings() gin.H { + settings := getImageStudioSettings() + pricingItems := model.GetPricing() + allowedModels := make([]string, 0) + modelSettings := make([]imageStudioModelSetting, 0) + models := make([]gin.H, 0) + seenModels := make(map[string]struct{}) + for _, item := range pricingItems { + if !containsString(item.EnableGroup, imageStudioGroup) { + continue + } + if !shouldAllowImageStudioModel(item.ModelName) { + continue + } + if _, ok := seenModels[item.ModelName]; ok { + continue + } + seenModels[item.ModelName] = struct{}{} + allowedModels = append(allowedModels, item.ModelName) + models = append(models, gin.H{ + "model_name": item.ModelName, + "enable_groups": item.EnableGroup, + "supported_endpoint_types": item.SupportedEndpointTypes, + }) + if ms, ok := getImageStudioModelSetting(item.ModelName); ok { + modelSettings = append(modelSettings, ms) + } + } + sort.Strings(allowedModels) + sort.SliceStable(models, func(i, j int) bool { + return fmt.Sprintf("%v", models[i]["model_name"]) < fmt.Sprintf("%v", models[j]["model_name"]) + }) + sort.SliceStable(modelSettings, func(i, j int) bool { + return modelSettings[i].Model < modelSettings[j].Model + }) + return gin.H{ + "enabled": settings.Enabled, + "default_model": settings.DefaultModel, + "allow_open_original": settings.AllowOpenOriginal, + "retention_minutes": settings.RetentionMinutes, + "allowed_models": allowedModels, + "models": models, + "model_settings": modelSettings, + } +} + func ensureImageStudioTaskCleanup() { imageStudioCleanupOnce.Do(func() { go func() { ticker := time.NewTicker(imageStudioTaskCleanupEvery) defer ticker.Stop() for range ticker.C { - cutoff := time.Now().Add(-imageStudioTaskTTL).Unix() + cutoff := time.Now().Add(-getImageStudioTaskTTL()).Unix() imageStudioTasks.Lock() for id, task := range imageStudioTasks.items { if task.UpdatedAt < cutoff { @@ -410,6 +714,50 @@ func validateImageStudioTokenAndModel(userID int, req imageStudioGenerateRequest if modelName == "" { return nil, fmt.Errorf("请选择图片模型") } + settings := getImageStudioSettings() + if !settings.Enabled { + return nil, fmt.Errorf("图片制作功能暂未开放") + } + if !shouldAllowImageStudioModel(modelName) { + return nil, fmt.Errorf("当前模型暂未开放图片制作,请联系管理员调整设置") + } + if modelSetting, ok := getImageStudioModelSetting(modelName); ok { + if !modelSetting.Enabled { + return nil, fmt.Errorf("当前模型已被管理员禁用") + } + if mode == imageStudioEditAction && !modelSetting.AllowEdit { + return nil, fmt.Errorf("当前模型未开放图片编辑") + } + if mode != imageStudioEditAction && !modelSetting.AllowGenerate { + return nil, fmt.Errorf("当前模型未开放图片生成") + } + } + if allowedSizes := getImageStudioAllowedSizesForModel(modelName); len(allowedSizes) > 0 { + resolvedSize := resolveImageStudioSize(modelName, req.Size) + allowed := false + for _, size := range allowedSizes { + if size == resolvedSize { + allowed = true + break + } + } + if !allowed { + return nil, fmt.Errorf("当前模型不允许使用所选尺寸") + } + } + if allowedQualities := getImageStudioAllowedQualitiesForModel(modelName); len(allowedQualities) > 0 { + resolvedQuality := normalizeImageStudioQuality(req.Quality) + allowed := false + for _, quality := range allowedQualities { + if quality == resolvedQuality { + allowed = true + break + } + } + if !allowed { + return nil, fmt.Errorf("当前模型不允许使用所选质量") + } + } if isGPTImage2Model(modelName) && !isValidGPTImage2Size(strings.TrimSpace(strings.ToLower(req.Size))) { return nil, fmt.Errorf("gpt-image-2 尺寸不合法:最长边不能超过 3840,边长需为 16 的倍数,长宽比不能超过 3:1,总像素需在 655360 到 8294400 之间") } @@ -556,7 +904,7 @@ func signImageStudioResult(relativePath string, expiresAt int64) string { func buildImageStudioResultFileURL(taskID string, index int, outputFormat string) string { relativePath := fmt.Sprintf("%s/%d.%s", taskID, index, getImageStudioResultExtension(outputFormat)) - expiresAt := time.Now().Add(imageStudioTaskTTL).Unix() + expiresAt := time.Now().Add(getImageStudioTaskTTL()).Unix() signature := signImageStudioResult(relativePath, expiresAt) return fmt.Sprintf("%s/%s?expires=%d&sig=%s", imageStudioResultURLPrefix, relativePath, expiresAt, signature) } @@ -925,6 +1273,10 @@ func SubmitImageStudioEdit(c *gin.Context) { common.ApiSuccess(c, task) } +func GetImageStudioSettings(c *gin.Context) { + common.ApiSuccess(c, buildImageStudioPublicSettings()) +} + func buildImageStudioTaskResultURL(taskID string, index int) string { return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index) } @@ -962,11 +1314,18 @@ func sanitizeImageStudioTaskForResponse(task *imageStudioTask) *imageStudioTask if !ok { continue } + taskResultURL := buildImageStudioTaskResultURL(task.ID, index) if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" { delete(item, "b64_json") - item["url"] = buildImageStudioTaskResultURL(task.ID, index) changed = true } + if url, _ := item["url"].(string); strings.TrimSpace(url) != "" && strings.TrimSpace(url) != taskResultURL { + item["raw_url"] = url + changed = true + } + item["url"] = taskResultURL + item["task_result_url"] = taskResultURL + changed = true } if !changed { return &clone @@ -1028,6 +1387,27 @@ func GetImageStudioTaskResult(c *gin.Context) { common.ApiErrorMsg(c, "图片结果不存在") return } + + if strings.TrimSpace(task.ResultDir) != "" { + outputFormat, _ := item["output_format"].(string) + filename := fmt.Sprintf("%d.%s", index, getImageStudioResultExtension(outputFormat)) + fullPath := filepath.Clean(filepath.Join(task.ResultDir, filename)) + resultDir := filepath.Clean(task.ResultDir) + prefix := resultDir + string(os.PathSeparator) + if fullPath == resultDir || !strings.HasPrefix(fullPath, prefix) { + common.ApiErrorMsg(c, "图片结果不存在") + return + } + info, statErr := os.Stat(fullPath) + if statErr == nil && !info.IsDir() { + c.Header("Cache-Control", "private, no-store, no-cache, max-age=0") + c.Header("Pragma", "no-cache") + c.Header("Content-Type", getImageStudioResultContentType(outputFormat)) + c.File(fullPath) + return + } + } + if url, _ := item["url"].(string); strings.TrimSpace(url) != "" { c.Redirect(http.StatusTemporaryRedirect, url) return diff --git a/controller/option.go b/controller/option.go index ecb1e25..136b8f5 100644 --- a/controller/option.go +++ b/controller/option.go @@ -89,6 +89,32 @@ func GetOptions(c *gin.Context) { Key: "CompletionRatioMeta", Value: buildCompletionRatioMetaValue(optionValues), }) + pricingItems := model.GetPricing() + imageStudioModels := make([]string, 0) + seenImageStudioModels := make(map[string]struct{}) + for _, item := range pricingItems { + enabledForImage := false + for _, group := range item.EnableGroup { + if strings.TrimSpace(group) == "image" { + enabledForImage = true + break + } + } + if !enabledForImage { + continue + } + if _, ok := seenImageStudioModels[item.ModelName]; ok { + continue + } + seenImageStudioModels[item.ModelName] = struct{}{} + imageStudioModels = append(imageStudioModels, item.ModelName) + } + if encoded, err := common.Marshal(imageStudioModels); err == nil { + options = append(options, &model.Option{ + Key: "ImageStudioAvailableModels", + Value: string(encoded), + }) + } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", diff --git a/model/ability.go b/model/ability.go index 1d7c53f..19541c7 100644 --- a/model/ability.go +++ b/model/ability.go @@ -111,31 +111,13 @@ func GetChannel(group string, model string, retry int) (*Channel, error) { if err != nil { return nil, err } - if common.UsingSQLite || common.UsingPostgreSQL { - err = channelQuery.Order("weight DESC").Find(&abilities).Error - } else { - err = channelQuery.Order("weight DESC").Find(&abilities).Error - } + err = channelQuery.Order("weight DESC, channel_id ASC").Find(&abilities).Error if err != nil { return nil, err } channel := Channel{} if len(abilities) > 0 { - // Randomly choose one - weightSum := uint(0) - for _, ability_ := range abilities { - weightSum += ability_.Weight + 10 - } - // Randomly choose one - weight := common.GetRandomInt(int(weightSum)) - for _, ability_ := range abilities { - weight -= int(ability_.Weight) + 10 - //log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight) - if weight <= 0 { - channel.Id = ability_.ChannelId - break - } - } + channel.Id = abilities[0].ChannelId } else { return nil, nil } diff --git a/model/channel_cache.go b/model/channel_cache.go index c9c5035..1185e92 100644 --- a/model/channel_cache.go +++ b/model/channel_cache.go @@ -1,9 +1,7 @@ package model import ( - "errors" "fmt" - "math/rand" "sort" "strings" "sync" @@ -115,13 +113,6 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, return nil, nil } - if len(channels) == 1 { - if channel, ok := channelsIDM[channels[0]]; ok { - return channel, nil - } - return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) - } - uniquePriorities := make(map[int]bool) for _, channelId := range channels { if channel, ok := channelsIDM[channelId]; ok { @@ -136,58 +127,50 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, } sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) - if retry >= len(uniquePriorities) { - retry = len(uniquePriorities) - 1 + if len(sortedUniquePriorities) == 0 { + return nil, nil + } + if retry < 0 { + retry = 0 + } + if retry >= len(channels) { + retry = len(channels) - 1 } - targetPriority := int64(sortedUniquePriorities[retry]) - // get the priority for the given retry number - var sumWeight = 0 - var targetChannels []*Channel - for _, channelId := range channels { - if channel, ok := channelsIDM[channelId]; ok { + orderedChannels := make([]*Channel, 0, len(channels)) + for _, priority := range sortedUniquePriorities { + targetPriority := int64(priority) + targetChannels := make([]*Channel, 0) + for _, channelId := range channels { + channel, ok := channelsIDM[channelId] + if !ok { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + } if channel.GetPriority() == targetPriority { - sumWeight += channel.GetWeight() targetChannels = append(targetChannels, channel) } - } else { - return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) } + sort.SliceStable(targetChannels, func(i, j int) bool { + left := targetChannels[i] + right := targetChannels[j] + if left.GetWeight() != right.GetWeight() { + return left.GetWeight() > right.GetWeight() + } + if left.GetPriority() != right.GetPriority() { + return left.GetPriority() > right.GetPriority() + } + return left.Id < right.Id + }) + orderedChannels = append(orderedChannels, targetChannels...) } - if len(targetChannels) == 0 { - return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority)) + if len(orderedChannels) == 0 { + return nil, nil } - - // smoothing factor and adjustment - smoothingFactor := 1 - smoothingAdjustment := 0 - - if sumWeight == 0 { - // when all channels have weight 0, set sumWeight to the number of channels and set smoothing adjustment to 100 - // each channel's effective weight = 100 - sumWeight = len(targetChannels) * 100 - smoothingAdjustment = 100 - } else if sumWeight/len(targetChannels) < 10 { - // when the average weight is less than 10, set smoothing factor to 100 - smoothingFactor = 100 + if retry >= len(orderedChannels) { + retry = len(orderedChannels) - 1 } - - // Calculate the total weight of all channels up to endIdx - totalWeight := sumWeight * smoothingFactor - - // Generate a random value in the range [0, totalWeight) - randomWeight := rand.Intn(totalWeight) - - // Find a channel based on its weight - for _, channel := range targetChannels { - randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment - if randomWeight < 0 { - return channel, nil - } - } - // return null if no channel is not found - return nil, errors.New("channel not found") + return orderedChannels[retry], nil } func CacheGetChannel(id int) (*Channel, error) { diff --git a/model/channel_cache_selection_test.go b/model/channel_cache_selection_test.go new file mode 100644 index 0000000..a8e378d --- /dev/null +++ b/model/channel_cache_selection_test.go @@ -0,0 +1,57 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" +) + +func TestGetRandomSatisfiedChannelOrderedByPriorityThenWeight(t *testing.T) { + origMemoryCacheEnabled := common.MemoryCacheEnabled + origCacheEnabled := group2model2channels + origChannels := channelsIDM + defer func() { + common.MemoryCacheEnabled = origMemoryCacheEnabled + group2model2channels = origCacheEnabled + channelsIDM = origChannels + }() + + common.MemoryCacheEnabled = true + group2model2channels = map[string]map[string][]int{ + "image": { + "gpt-image-2": {1, 2, 3}, + }, + } + channelsIDM = map[int]*Channel{ + 1: {Id: 1, Priority: int64Ptr(10), Weight: uintPtr(50)}, + 2: {Id: 2, Priority: int64Ptr(10), Weight: uintPtr(20)}, + 3: {Id: 3, Priority: int64Ptr(5), Weight: uintPtr(99)}, + } + + first, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if first == nil || first.Id != 1 { + t.Fatalf("expected first channel id 1, got %+v", first) + } + + second, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if second == nil || second.Id != 2 { + t.Fatalf("expected second channel id 2, got %+v", second) + } + + third, err := GetRandomSatisfiedChannel("image", "gpt-image-2", 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if third == nil || third.Id != 3 { + t.Fatalf("expected third channel id 3, got %+v", third) + } +} + +func int64Ptr(v int64) *int64 { return &v } +func uintPtr(v uint) *uint { return &v } diff --git a/model/log.go b/model/log.go index 1cb3b68..600fd36 100644 --- a/model/log.go +++ b/model/log.go @@ -221,11 +221,9 @@ type CacheDashboardMetrics struct { } type cacheMetricRow struct { - CreatedAt int64 `gorm:"column:created_at"` - PromptTokens int `gorm:"column:prompt_tokens"` - CompletionTokens int `gorm:"column:completion_tokens"` - ModelName string `gorm:"column:model_name"` - Other string `gorm:"column:other"` + CreatedAt int64 `gorm:"column:created_at"` + PromptTokens int `gorm:"column:prompt_tokens"` + Other string `gorm:"column:other"` } type DashboardConsumeStats struct { @@ -330,14 +328,26 @@ func finalizeCacheMetrics(m *CacheDashboardMetrics) { } } +var dashboardMetricsLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +func getDashboardDayStartUnix(now time.Time) int64 { + localNow := now.In(dashboardMetricsLocation) + return time.Date( + localNow.Year(), + localNow.Month(), + localNow.Day(), + 0, 0, 0, 0, + dashboardMetricsLocation, + ).Unix() +} + func GetUserCacheDashboardMetrics(userId int) (CacheDashboardMetrics, CacheDashboardMetrics, error) { - now := time.Now() - start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix() + start := getDashboardDayStartUnix(time.Now()) overall := CacheDashboardMetrics{} today := CacheDashboardMetrics{} var rows []cacheMetricRow err := LOG_DB.Model(&Log{}). - Select("created_at, prompt_tokens, completion_tokens, model_name, other"). + Select("created_at, prompt_tokens, other"). Where("user_id = ? AND type = ?", userId, LogTypeConsume). Order("id ASC"). FindInBatches(&rows, 1000, func(tx *gorm.DB, batch int) error { diff --git a/model/log_dashboard_metrics_test.go b/model/log_dashboard_metrics_test.go new file mode 100644 index 0000000..0cef848 --- /dev/null +++ b/model/log_dashboard_metrics_test.go @@ -0,0 +1,40 @@ +package model + +import ( + "testing" + "time" +) + +func TestGetDashboardDayStartUnixUsesShanghai(t *testing.T) { + utcTime := time.Date(2026, 5, 5, 0, 30, 0, 0, time.UTC) + got := getDashboardDayStartUnix(utcTime) + + want := time.Date(2026, 5, 5, 0, 0, 0, 0, dashboardMetricsLocation).Unix() + if got != want { + t.Fatalf("expected Shanghai day start %d, got %d", want, got) + } +} + +func TestAccumulateCacheMetrics(t *testing.T) { + row := cacheMetricRow{ + PromptTokens: 100, + Other: `{"cache_read_tokens":40,"cache_write_tokens":10}`, + } + metrics := CacheDashboardMetrics{} + + accumulateCacheMetrics(&metrics, row) + finalizeCacheMetrics(&metrics) + + if metrics.CacheReadTokens != 40 { + t.Fatalf("expected cache read 40, got %d", metrics.CacheReadTokens) + } + if metrics.CacheWriteTokens != 10 { + t.Fatalf("expected cache write 10, got %d", metrics.CacheWriteTokens) + } + if metrics.CacheTokens != 50 { + t.Fatalf("expected cache tokens 50, got %d", metrics.CacheTokens) + } + if metrics.CacheHitRate <= 0 { + t.Fatalf("expected positive hit rate, got %f", metrics.CacheHitRate) + } +} diff --git a/model/option.go b/model/option.go index 4984af1..f91d686 100644 --- a/model/option.go +++ b/model/option.go @@ -162,6 +162,8 @@ func InitOptionMap() { common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString() common.OptionMap["ImageStudioBannedWords"] = setting.ImageStudioBannedWordsToString() + common.OptionMap["ImageStudioSettings"] = `{"enabled":true,"default_model":"gpt-image-2","allow_open_original":true,"retention_minutes":30,"allowed_models":[],"model_settings":[]}` + common.OptionMap["ImageStudioRetentionMinutes"] = "30" common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString() @@ -503,6 +505,10 @@ func updateOptionMap(key string, value string) (err error) { setting.ImageStudioSafetyPromptFromString(value) case "ImageStudioBannedWords": setting.ImageStudioBannedWordsFromString(value) + case "ImageStudioSettings": + // 由控制器/前端按 JSON 读取;这里仅保留在 OptionMap 中供运行时使用。 + case "ImageStudioRetentionMinutes": + // 由图片制作运行时读取 OptionMap 并解析,避免再引入重复全局状态。 case "AutomaticDisableKeywords": operation_setting.AutomaticDisableKeywordsFromString(value) case "AutomaticDisableStatusCodes": diff --git a/router/api-router.go b/router/api-router.go index a59cbaf..3427fcd 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -263,6 +263,7 @@ func SetApiRouter(router *gin.Engine) { imageStudioRoute := apiRouter.Group("/image-studio") imageStudioRoute.Use(middleware.UserAuth()) { + imageStudioRoute.GET("/settings", controller.GetImageStudioSettings) imageStudioRoute.POST("/generate", controller.SubmitImageStudioGenerate) imageStudioRoute.POST("/edit", controller.SubmitImageStudioEdit) imageStudioRoute.GET("/task/:id", controller.GetImageStudioTask) diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef..d9504ac 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -45,14 +45,14 @@ func (p *RetryParam) ResetRetryNextTry() { p.resetNextTry = true } -// CacheGetRandomSatisfiedChannel tries to get a random channel that satisfies the requirements. -// 尝试获取一个满足要求的随机渠道。 +// CacheGetRandomSatisfiedChannel tries to get the next satisfied channel within the same group. +// 按同一分组内的优先级和顺序选择下一个满足要求的渠道。 // // For "auto" tokenGroup with cross-group Retry enabled: // 对于启用了跨分组重试的 "auto" tokenGroup: // -// - Each group will exhaust all its priorities before moving to the next group. -// 每个分组会用完所有优先级后才会切换到下一个分组。 +// - Each group will exhaust all its channels before moving to the next group. +// 每个分组会用完所有候选渠道后才会切换到下一个分组。 // // - Uses ContextKeyAutoGroupIndex to track current group index. // 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。 @@ -60,11 +60,11 @@ func (p *RetryParam) ResetRetryNextTry() { // - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started. // 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。 // -// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group. -// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。 +// - priorityRetry = Retry - startRetryIndex, represents the channel attempt index within current group. +// priorityRetry = Retry - startRetryIndex,表示当前分组内的候选渠道尝试序号。 // -// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group. -// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。 +// - When GetRandomSatisfiedChannel returns nil (group exhausted), moves to next group. +// 当 GetRandomSatisfiedChannel 返回 nil(当前分组候选已用尽)时,切换到下一个分组。 // // Example flow (2 groups, each with 2 priorities, RetryTimes=3): // 示例流程(2个分组,每个有2个优先级,RetryTimes=3): diff --git a/web/package.json b/web/package.json index 97c7c82..f8495f9 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", + "antd": "^5.29.3", "axios": "1.13.5", "clsx": "^2.1.1", "dayjs": "^1.11.11", diff --git a/web/src/App.jsx b/web/src/App.jsx index aaf4a58..25217f6 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -43,11 +43,9 @@ import Pricing from './pages/Pricing'; import Task from './pages/Task'; import ModelPage from './pages/Model'; import ModelDeploymentPage from './pages/ModelDeployment'; -import Playground from './pages/Playground'; import Subscription from './pages/Subscription'; import OAuth2Callback from './components/auth/OAuth2Callback'; import PersonalSetting from './components/settings/PersonalSetting'; -import ImageStudio from './pages/ImageStudio'; import Setup from './pages/Setup'; import SetupCheck from './components/layout/SetupCheck'; @@ -56,6 +54,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard')); const About = lazy(() => import('./pages/About')); const UserAgreement = lazy(() => import('./pages/UserAgreement')); const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy')); +const Playground = lazy(() => import('./pages/Playground')); +const ImageStudio = lazy(() => import('./pages/ImageStudio')); function DynamicOAuth2Callback() { const { provider } = useParams(); @@ -152,7 +152,9 @@ function App() { path='/console/playground' element={ - + } key={location.pathname}> + + } /> diff --git a/web/src/components/dashboard/index.jsx b/web/src/components/dashboard/index.jsx index 51567ec..e5d6337 100644 --- a/web/src/components/dashboard/index.jsx +++ b/web/src/components/dashboard/index.jsx @@ -88,13 +88,14 @@ const Dashboard = () => { // ========== 数据处理 ========== const initChart = async () => { - await dashboardData.loadQuotaData().then((data) => { - if (data && data.length > 0) { - dashboardCharts.updateChartData(data); - } - }); - await dashboardData.loadUptimeData(); - await dashboardData.loadCacheStats?.(); + const [data] = await Promise.all([ + dashboardData.loadQuotaData(), + dashboardData.loadUptimeData(), + dashboardData.loadCacheStats?.(), + ]); + if (data && data.length > 0) { + dashboardCharts.updateChartData(data); + } }; const handleRefresh = async () => { diff --git a/web/src/components/settings/DrawingSetting.jsx b/web/src/components/settings/DrawingSetting.jsx index a714b69..5a0edf1 100644 --- a/web/src/components/settings/DrawingSetting.jsx +++ b/web/src/components/settings/DrawingSetting.jsx @@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com import React, { useEffect, useState } from 'react'; import { Card, Spin } from '@douyinfe/semi-ui'; import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing'; +import SettingsImageStudio from '../../pages/Setting/Drawing/SettingsImageStudio'; import { API, showError, toBoolean } from '../../helpers'; const DrawingSetting = () => { @@ -76,6 +77,9 @@ const DrawingSetting = () => { + + + ); diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 3474214..3c5395b 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -409,10 +409,10 @@ const NotificationSettings = ({ } placeholder={t('请输入预警额度')} data={[ - { value: 100000, label: '0.2$' }, - { value: 500000, label: '1$' }, - { value: 1000000, label: '2$' }, - { value: 5000000, label: '10$' }, + { value: 100000, label: '0.2¥' }, + { value: 500000, label: '1¥' }, + { value: 1000000, label: '2¥' }, + { value: 5000000, label: '10¥' }, ]} onChange={(val) => handleFormChange('warningThreshold', val)} prefix={} @@ -515,7 +515,7 @@ const NotificationSettings = ({ title: '额度预警通知', content: '您的额度即将用尽,当前剩余额度为 {{value}}', - values: ['$0.99'], + values: ['¥0.99'], timestamp: 1739950503, }} title='webhook' diff --git a/web/src/components/table/channels/ChannelsColumnDefs.jsx b/web/src/components/table/channels/ChannelsColumnDefs.jsx index 5d748c0..077f839 100644 --- a/web/src/components/table/channels/ChannelsColumnDefs.jsx +++ b/web/src/components/table/channels/ChannelsColumnDefs.jsx @@ -574,7 +574,7 @@ export const getChannelsColumns = ({ }, { key: COLUMN_KEYS.PRIORITY, - title: t('优先级'), + title: t('同组优先级'), dataIndex: 'priority', render: (text, record, index) => { if (record.children === undefined) { @@ -602,9 +602,9 @@ export const getChannelsColumns = ({ keepFocus={true} onBlur={(e) => { Modal.warning({ - title: t('修改子渠道优先级'), + title: t('修改子渠道同组优先级'), content: - t('确定要修改所有子渠道优先级为 ') + + t('确定要修改所有子渠道同组优先级为 ') + e.target.value + t(' 吗?'), onOk: () => { @@ -629,7 +629,7 @@ export const getChannelsColumns = ({ }, { key: COLUMN_KEYS.WEIGHT, - title: t('权重'), + title: t('同优先级顺序'), dataIndex: 'weight', render: (text, record, index) => { if (record.children === undefined) { @@ -657,9 +657,9 @@ export const getChannelsColumns = ({ keepFocus={true} onBlur={(e) => { Modal.warning({ - title: t('修改子渠道权重'), + title: t('修改子渠道同优先级顺序'), content: - t('确定要修改所有子渠道权重为 ') + + t('确定要修改所有子渠道同优先级顺序为 ') + e.target.value + t(' 吗?'), onOk: () => { diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index 7f7015e..9b5575b 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -3540,8 +3540,8 @@ const EditChannelModal = (props) => { handleInputChange('priority', value) @@ -3552,8 +3552,8 @@ const EditChannelModal = (props) => { handleInputChange('weight', value) diff --git a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx index f004fe5..e5a255c 100644 --- a/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx +++ b/web/src/components/table/model-deployments/modals/ViewDetailsModal.jsx @@ -519,7 +519,7 @@ const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
{t('已支付金额')} - $ + ¥ {details.amount_paid ? details.amount_paid.toFixed(2) : '0.00'}{' '} diff --git a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx index 7c4bdbc..da67029 100644 --- a/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx +++ b/web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx @@ -61,8 +61,8 @@ const PricingDisplaySettings = ({ ]; const currencyItems = [ - { value: 'USD', label: 'USD ($)' }, - { value: 'CNY', label: 'CNY (¥)' }, + { value: 'USD', label: 'USD (¥)' }, + { value: 'CNY', label: 'CNY (¥)' }, { value: 'CUSTOM', label: t('自定义货币') }, ]; diff --git a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx index bcde726..ad01993 100644 --- a/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx +++ b/web/src/components/table/redemptions/modals/EditRedemptionModal.jsx @@ -307,12 +307,12 @@ const EditRedemptionModal = (props) => { Number(values.quota) || 0, )} data={[ - { value: 500000, label: '1$' }, - { value: 5000000, label: '10$' }, - { value: 25000000, label: '50$' }, - { value: 50000000, label: '100$' }, - { value: 250000000, label: '500$' }, - { value: 500000000, label: '1000$' }, + { value: 500000, label: '1¥' }, + { value: 5000000, label: '10¥' }, + { value: 25000000, label: '50¥' }, + { value: 50000000, label: '100¥' }, + { value: 250000000, label: '500¥' }, + { value: 500000000, label: '1000¥' }, ]} showClear /> diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index 94e6c65..1eaca35 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -605,12 +605,12 @@ const EditTokenModal = (props) => { : [{ required: true, message: t('请输入额度') }] } data={[ - { value: 500000, label: '1$' }, - { value: 5000000, label: '10$' }, - { value: 25000000, label: '50$' }, - { value: 50000000, label: '100$' }, - { value: 250000000, label: '500$' }, - { value: 500000000, label: '1000$' }, + { value: 500000, label: '1¥' }, + { value: 5000000, label: '10¥' }, + { value: 25000000, label: '50¥' }, + { value: 50000000, label: '100¥' }, + { value: 250000000, label: '500¥' }, + { value: 500000000, label: '1000¥' }, ]} /> diff --git a/web/src/components/topup/RechargeCard.jsx b/web/src/components/topup/RechargeCard.jsx index f37d129..a1255d3 100644 --- a/web/src/components/topup/RechargeCard.jsx +++ b/web/src/components/topup/RechargeCard.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Avatar, Typography, @@ -105,6 +105,21 @@ const RechargeCard = ({ const [activeTab, setActiveTab] = useState('topup'); const shouldShowSubscription = !subscriptionLoading && subscriptionPlans.length > 0; + const nonWaffoPayMethods = useMemo( + () => (payMethods || []).filter((method) => method.type !== 'waffo'), + [payMethods], + ); + const currencyConfig = useMemo(() => getCurrencyConfig(), []); + const usdExchangeRate = useMemo(() => { + const statusStr = localStorage.getItem('status'); + try { + if (statusStr) { + const status = JSON.parse(statusStr); + return status?.usd_exchange_rate || 7; + } + } catch (e) {} + return 7; + }, []); useEffect(() => { if (initialTabSetRef.current) return; @@ -291,11 +306,11 @@ const RechargeCard = ({ style={{ width: '100%' }} /> - {payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && ( + {nonWaffoPayMethods.length > 0 && ( - {payMethods.filter(m => m.type !== 'waffo').map((payMethod) => { + {nonWaffoPayMethods.map((payMethod) => { const minTopupVal = Number(payMethod.min_topup) || 0; const isStripe = payMethod.type === 'stripe'; const disabled = @@ -366,22 +381,18 @@ const RechargeCard = ({ label={
{t('选择充值额度')} - {(() => { - const { symbol, rate, type } = getCurrencyConfig(); - if (type === 'USD') return null; - - return ( - - (1 $ = {rate.toFixed(2)} {symbol}) - - ); - })()} + {currencyConfig.type !== 'USD' && ( + + (1 ¥ = {currencyConfig.rate.toFixed(2)}{' '} + {currencyConfig.symbol}) + + )}
} > @@ -396,15 +407,7 @@ const RechargeCard = ({ const save = originalPrice - discountedPrice; // 根据当前货币类型换算显示金额和数量 - const { symbol, rate, type } = getCurrencyConfig(); - const statusStr = localStorage.getItem('status'); - let usdRate = 7; // 默认CNY汇率 - try { - if (statusStr) { - const s = JSON.parse(statusStr); - usdRate = s?.usd_exchange_rate || 7; - } - } catch (e) { } + const { symbol, rate, type } = currencyConfig; let displayValue = preset.value; // 显示的数量 let displayActualPay = actualPay; @@ -412,16 +415,16 @@ const RechargeCard = ({ if (type === 'USD') { // 数量保持USD,价格从CNY转USD - displayActualPay = actualPay / usdRate; - displaySave = save / usdRate; + displayActualPay = actualPay / usdExchangeRate; + displaySave = save / usdExchangeRate; } else if (type === 'CNY') { // 数量转CNY,价格已是CNY - displayValue = preset.value * usdRate; + displayValue = preset.value * usdExchangeRate; } else if (type === 'CUSTOM') { // 数量和价格都转自定义货币 displayValue = preset.value * rate; - displayActualPay = (actualPay / usdRate) * rate; - displaySave = (save / usdRate) * rate; + displayActualPay = (actualPay / usdExchangeRate) * rate; + displaySave = (save / usdExchangeRate) * rate; } return ( @@ -540,7 +543,7 @@ const RechargeCard = ({ {t('充值额度')}: {product.quota}
- {product.currency === 'EUR' ? '€' : '$'} + {product.currency === 'EUR' ? '€' : '¥'} {product.price}
diff --git a/web/src/components/topup/index.jsx b/web/src/components/topup/index.jsx index a33815c..76833b9 100644 --- a/web/src/components/topup/index.jsx +++ b/web/src/components/topup/index.jsx @@ -753,7 +753,7 @@ const TopUp = () => { {/* Creem 充值确认模态框 */} { {t('产品名称')}:{selectedCreemProduct.name}

- {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'} + {t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '¥'} {selectedCreemProduct.price}

diff --git a/web/src/components/topup/modals/TopupHistoryModal.jsx b/web/src/components/topup/modals/TopupHistoryModal.jsx index 9659e92..23436b2 100644 --- a/web/src/components/topup/modals/TopupHistoryModal.jsx +++ b/web/src/components/topup/modals/TopupHistoryModal.jsx @@ -197,7 +197,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => { title: t('支付金额'), dataIndex: 'money', key: 'money', - render: (money) => ¥{money.toFixed(2)}, + render: (money) => ¥{money.toFixed(2)}, }, { title: t('状态'), diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index e2bce61..d3e4fdc 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -999,9 +999,9 @@ export function renderQuotaNumberWithDigit(num, digits = 2) { const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; num = num.toFixed(digits); if (quotaDisplayType === 'CNY') { - return '¥' + num; + return '¥' + num; } else if (quotaDisplayType === 'USD') { - return '$' + num; + return '¥' + num; } else if (quotaDisplayType === 'CUSTOM') { const statusStr = localStorage.getItem('status'); let symbol = '¤'; @@ -1077,7 +1077,7 @@ export function renderQuotaWithAmount(amount) { : amount; if (quotaDisplayType === 'CNY') { - return '¥' + formattedAmount; + return '¥' + formattedAmount; } else if (quotaDisplayType === 'CUSTOM') { const statusStr = localStorage.getItem('status'); let symbol = '¤'; @@ -1089,7 +1089,7 @@ export function renderQuotaWithAmount(amount) { } catch (e) {} return symbol + formattedAmount; } - return '$' + formattedAmount; + return '¥' + formattedAmount; } /** @@ -1100,11 +1100,11 @@ export function getCurrencyConfig() { const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD'; const statusStr = localStorage.getItem('status'); - let symbol = '$'; + let symbol = '¥'; let rate = 1; if (quotaDisplayType === 'CNY') { - symbol = '¥'; + symbol = '¥'; try { if (statusStr) { const s = JSON.parse(statusStr); @@ -1144,7 +1144,7 @@ export function renderQuota(quota, digits = 2) { return renderNumber(quota); } const resultUSD = quota / quotaPerUnit; - let symbol = '$'; + let symbol = '¥'; let value = resultUSD; if (quotaDisplayType === 'CNY') { const statusStr = localStorage.getItem('status'); @@ -1156,7 +1156,7 @@ export function renderQuota(quota, digits = 2) { } } catch (e) {} value = resultUSD * usdRate; - symbol = '¥'; + symbol = '¥'; } else if (quotaDisplayType === 'CUSTOM') { const statusStr = localStorage.getItem('status'); let symbolCustom = '¤'; @@ -2375,7 +2375,7 @@ export function renderAudioModelPrice( ]); } - // 1 ratio = $0.002 / 1K tokens + // 1 ratio = ¥0.002 / 1K tokens if (modelPrice !== -1) { return i18next.t( '模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}', diff --git a/web/src/helpers/utils.jsx b/web/src/helpers/utils.jsx index 3757f9d..77ab7b1 100644 --- a/web/src/helpers/utils.jsx +++ b/web/src/helpers/utils.jsx @@ -676,9 +676,9 @@ export const calculateModelPrice = ({ }; } - let symbol = '$'; + let symbol = '¥'; if (currency === 'CNY') { - symbol = '¥'; + symbol = '¥'; } else if (currency === 'CUSTOM') { try { const statusStr = localStorage.getItem('status'); diff --git a/web/src/hooks/channels/useChannelsData.jsx b/web/src/hooks/channels/useChannelsData.jsx index 37ee501..8eae0c0 100644 --- a/web/src/hooks/channels/useChannelsData.jsx +++ b/web/src/hooks/channels/useChannelsData.jsx @@ -620,7 +620,7 @@ export const useChannelsData = () => { switch (type) { case 'priority': if (data.priority === undefined || data.priority === '') { - showInfo('优先级必须是整数!'); + showInfo('同组优先级必须是整数!'); return; } data.priority = parseInt(data.priority); @@ -631,7 +631,7 @@ export const useChannelsData = () => { data.weight < 0 || data.weight === '' ) { - showInfo('权重必须是非负整数!'); + showInfo('同优先级顺序必须是非负整数!'); return; } data.weight = parseInt(data.weight); diff --git a/web/src/hooks/dashboard/useDashboardData.js b/web/src/hooks/dashboard/useDashboardData.js index d3bf3dd..a6ae4e2 100644 --- a/web/src/hooks/dashboard/useDashboardData.js +++ b/web/src/hooks/dashboard/useDashboardData.js @@ -243,9 +243,11 @@ export const useDashboardData = (userState, userDispatch, statusState) => { }, [userDispatch]); const refresh = useCallback(async () => { - const data = await loadQuotaData(); - await loadUptimeData(); - await loadCacheStats(); + const [data] = await Promise.all([ + loadQuotaData(), + loadUptimeData(), + loadCacheStats(), + ]); return data; }, [loadQuotaData, loadUptimeData, loadCacheStats]); diff --git a/web/src/hooks/model-pricing/useModelPricingData.jsx b/web/src/hooks/model-pricing/useModelPricingData.jsx index 1fc0f58..bac726a 100644 --- a/web/src/hooks/model-pricing/useModelPricingData.jsx +++ b/web/src/hooks/model-pricing/useModelPricingData.jsx @@ -185,7 +185,7 @@ export const useModelPricingData = () => { } if (currency === 'CNY') { - return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; + return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`; } else if (currency === 'CUSTOM') { return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`; } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a1e37a1..d2a368f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -30,7 +30,7 @@ "• 需要特定的请求头或认证": "• Specific headers or authentication are required", "© {{currentYear}}": "© {{currentYear}}", "| 基于": " | Based on ", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - Lowest", "0 表示不限": "0 means unlimited", "0.002-1之间的小数": "Decimal between 0.002-1", @@ -435,7 +435,7 @@ "例如:0001": "e.g.: 0001", "例如:1000": "e.g.: 1000", "例如:100000": "e.g.: 100000", - "例如:2,就是最低充值2$": "e.g.: 2, means minimum top-up is $2", + "例如:2,就是最低充值2$": "e.g.: 2, means minimum top-up is ¥2", "例如:2000": "e.g.: 2000", "例如:4.99": "e.g.: 4.99", "例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599", @@ -745,7 +745,7 @@ "剩余时间": "Remaining Time", "剩余额度": "Remaining quota", "剩余额度/总额度": "Remaining/Total", - "剩余额度$": "Remaining quota $", + "剩余额度$": "Remaining quota ¥", "功能特性": "Features", "加入渠道": "Join Channel", "加入预填组": "Join Pre-filled Group", @@ -2170,7 +2170,7 @@ "确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?", "确定要修改所有子渠道优先级为 ": "Confirm to modify all sub-channel priorities to ", "确定要修改所有子渠道权重为 ": "Confirm to modify all sub-channel weights to ", - "确定要充值 $": "Confirm to recharge $", + "确定要充值 $": "Confirm to recharge ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Are you sure you want to delete supplier \"{{name}}\"? This operation is irreversible.", "确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?", "确定要删除所选的 {{count}} 个令牌吗?_one": "Are you sure you want to delete the selected {{count}} token?", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index e1d5757..5e3b121 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -33,7 +33,7 @@ "• 需要特定的请求头或认证": "• Des en-têtes ou une authentification spécifiques sont requis", "© {{currentYear}}": "© {{currentYear}}", "| 基于": " | Basé sur ", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - La plus basse", "0 表示不限": "0 signifie illimité", "0.002-1之间的小数": "Décimal entre 0,002-1", @@ -430,7 +430,7 @@ "例如:0001": "Par exemple : 0001", "例如:1000": "Par exemple : 1000", "例如:100000": "Ex. : 100000", - "例如:2,就是最低充值2$": "Par exemple : 2, c'est-à-dire un minimum de 2$ de recharge", + "例如:2,就是最低充值2$": "Par exemple : 2, c'est-à-dire un minimum de 2¥ de recharge", "例如:2000": "Par exemple : 2000", "例如:4.99": "Ex. : 4.99", "例如:401, 403, 429, 500-599": "ex. : 401, 403, 429, 500-599", @@ -729,7 +729,7 @@ "剩余时间": "Remaining Time", "剩余额度": "Quota restant", "剩余额度/总额度": "Restant/Total", - "剩余额度$": "Quota restant $", + "剩余额度$": "Quota restant ¥", "功能特性": "Fonctionnalités", "加入渠道": "Join Channel", "加入预填组": "Rejoindre un groupe pré-rempli", @@ -2135,7 +2135,7 @@ "确定清除所有失效兑换码?": "Êtes-vous sûr de vouloir effacer tous les codes d'échange non valides ?", "确定要修改所有子渠道优先级为 ": "Confirmer la modification de toutes les priorités des sous-canaux en ", "确定要修改所有子渠道权重为 ": "Confirmer la modification de tous les poids des sous-canaux en ", - "确定要充值 $": "Confirmer la recharge de $", + "确定要充值 $": "Confirmer la recharge de ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Êtes-vous sûr de vouloir supprimer le fournisseur \"{{name}}\" ? Cette opération est irréversible.", "确定要删除所有已自动禁用的密钥吗?": "Êtes-vous sûr de vouloir supprimer toutes les clés désactivées automatiquement ?", "确定要删除所选的 {{count}} 个令牌吗?_one": "Êtes-vous sûr de vouloir supprimer le jeton sélectionné ?", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 9f920a5..9f57e1b 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -29,7 +29,7 @@ "• 需要特定的请求头或认证": "• Specific headers or authentication are required", "© {{currentYear}}": "© {{currentYear}}", "| 基于": "| ベース: ", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - 最低", "0 表示不限": "0 は無制限を意味します", "0.002-1之间的小数": "0.002~1の小数", @@ -426,7 +426,7 @@ "例如:0001": "例:0001", "例如:1000": "例:1000", "例如:100000": "e.g.: 100000", - "例如:2,就是最低充值2$": "例:2(最低チャージ額$2)", + "例如:2,就是最低充值2$": "例:2(最低チャージ額¥2)", "例如:2000": "例:2000", "例如:4.99": "e.g.: 4.99", "例如:401, 403, 429, 500-599": "例:401, 403, 429, 500-599", @@ -720,7 +720,7 @@ "剩余时间": "Remaining Time", "剩余额度": "残りクォータ", "剩余额度/总额度": "残りクォータ/総クォータ", - "剩余额度$": "残高 ($)", + "剩余额度$": "残高 (¥)", "功能特性": "機能", "加入渠道": "Join Channel", "加入预填组": "事前入力グループへの参加", @@ -2118,7 +2118,7 @@ "确定清除所有失效兑换码?": "すべての無効な引き換えコードを削除してもよろしいですか?", "确定要修改所有子渠道优先级为 ": "すべてのサブチャネルの優先度を", "确定要修改所有子渠道权重为 ": "すべてのサブチャネルのウェイトを", - "确定要充值 $": "Confirm to recharge $", + "确定要充值 $": "Confirm to recharge ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻すことができません。", "确定要删除所有已自动禁用的密钥吗?": "自動的に無効になったすべてのAPIキーを削除してもよろしいですか?", "确定要删除所选的 {{count}} 个令牌吗?_one": "選択した{{count}}個のトークンを削除してもよろしいですか?_one", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 123c018..8e25f3c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -36,7 +36,7 @@ "• 需要特定的请求头或认证": "• Требуются специальные заголовки или авторизация", "© {{currentYear}}": "© {{currentYear}}", "| 基于": "| Основано на", - "$/1M tokens": "$/1M токенов", + "$/1M tokens": "¥/1M токенов", "0 - 最低": "0 - Минимум", "0 表示不限": "0 означает без лимита", "0.002-1之间的小数": "Десятичное число между 0.002-1", @@ -433,7 +433,7 @@ "例如:0001": "например: 0001", "例如:1000": "например: 1000", "例如:100000": "Например: 100000", - "例如:2,就是最低充值2$": "например: 2, это минимальное пополнение 2$", + "例如:2,就是最低充值2$": "например: 2, это минимальное пополнение 2¥", "例如:2000": "например: 2000", "例如:4.99": "Например: 4.99", "例如:401, 403, 429, 500-599": "напр.: 401, 403, 429, 500-599", @@ -735,7 +735,7 @@ "剩余时间": "Remaining Time", "剩余额度": "Оставшаяся квота", "剩余额度/总额度": "Оставшаяся квота/Общая квота", - "剩余额度$": "Оставшаяся квота$", + "剩余额度$": "Оставшаяся квота¥", "功能特性": "Функциональные возможности", "加入渠道": "Join Channel", "加入预填组": "Присоединиться к группе предварительного заполнения", @@ -2147,7 +2147,7 @@ "确定清除所有失效兑换码?": "Подтвердить очистку всех недействительных кодов купонов?", "确定要修改所有子渠道优先级为 ": "Подтвердить изменение приоритета всех дочерних каналов на ", "确定要修改所有子渠道权重为 ": "Подтвердить изменение веса всех дочерних каналов на ", - "确定要充值 $": "Подтвердить пополнение на $", + "确定要充值 $": "Подтвердить пополнение на ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Подтвердить удаление поставщика \"{{name}}\"? Это действие нельзя отменить.", "确定要删除所有已自动禁用的密钥吗?": "Подтвердить удаление всех автоматически отключенных ключей?", "确定要删除所选的 {{count}} 个令牌吗?_one": "Подтвердить удаление выбранного {{count}} токена?", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 33c2f85..e0d0165 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -29,7 +29,7 @@ "• 需要特定的请求头或认证": "• Specific headers or authentication are required", "© {{currentYear}}": "© {{currentYear}}", "| 基于": " | Dựa trên ", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - Thấp nhất", "0 表示不限": "0 nghĩa là không giới hạn", "0.002-1之间的小数": "Số thập phân giữa 0.002-1", @@ -427,7 +427,7 @@ "例如:0001": "ví dụ: 0001", "例如:1000": "ví dụ: 1000", "例如:100000": "Ví dụ: 100000", - "例如:2,就是最低充值2$": "ví dụ: 2, nghĩa là nạp tối thiểu $2", + "例如:2,就是最低充值2$": "ví dụ: 2, nghĩa là nạp tối thiểu ¥2", "例如:2000": "ví dụ: 2000", "例如:4.99": "Ví dụ: 4.99", "例如:401, 403, 429, 500-599": "VD: 401, 403, 429, 500-599", @@ -721,7 +721,7 @@ "剩余时间": "Remaining Time", "剩余额度": "Hạn ngạch còn lại", "剩余额度/总额度": "Còn lại/Tổng cộng", - "剩余额度$": "Hạn ngạch còn lại $", + "剩余额度$": "Hạn ngạch còn lại ¥", "功能特性": "Tính năng", "加入渠道": "Join Channel", "加入预填组": "Tham gia nhóm điền sẵn", @@ -2347,7 +2347,7 @@ "确定清除所有失效兑换码?": "Bạn có chắc chắn muốn xóa tất cả các mã đổi thưởng không hợp lệ không?", "确定要修改所有子渠道优先级为 ": "Xác nhận sửa đổi tất cả các ưu tiên kênh con thành ", "确定要修改所有子渠道权重为 ": "Xác nhận sửa đổi tất cả các trọng số kênh con thành ", - "确定要充值 $": "Confirm to recharge $", + "确定要充值 $": "Confirm to recharge ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Bạn có chắc chắn muốn xóa nhà cung cấp \"{{name}}\" không? Thao tác này không thể hoàn tác.", "确定要删除吗?": "Bạn có chắc chắn muốn xóa không?", "确定要删除所有已自动禁用的密钥吗?": "Bạn có chắc chắn muốn xóa tất cả các khóa bị vô hiệu hóa tự động không?", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index d686d1c..8a897d2 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -24,7 +24,7 @@ "• 需要特定的请求头或认证": "• 需要特定的请求头或认证", "© {{currentYear}}": "© {{currentYear}}", "| 基于": "| 基于", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - 最低", "0.002-1之间的小数": "0.002-1之间的小数", "0.1以上的小数": "0.1以上的小数", @@ -350,7 +350,7 @@ "例如:0001": "例如:0001", "例如:1000": "例如:1000", "例如:100000": "例如:100000", - "例如:2,就是最低充值2$": "例如:2,就是最低充值2$", + "例如:2,就是最低充值2$": "例如:2,就是最低充值2¥", "例如:2000": "例如:2000", "例如:4.99": "例如:4.99", "例如:7,就是7元/美金": "例如:7,就是7元/美金", @@ -594,7 +594,8 @@ "剩余时间": "剩余时间", "剩余额度": "剩余额度", "剩余额度/总额度": "剩余额度/总额度", - "剩余额度$": "剩余额度$", + "剩余额度$": "剩余额度¥", + "剩余额度¥": "剩余额度¥", "功能特性": "功能特性", "加入渠道": "加入渠道", "加入预填组": "加入预填组", @@ -1749,7 +1750,8 @@ "确定清除所有失效兑换码?": "确定清除所有失效兑换码?", "确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ", "确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ", - "确定要充值 $": "确定要充值 $", + "确定要充值 $": "确定要充值 ¥", + "确定要充值 ¥": "确定要充值 ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。", "确定要删除所有已自动禁用的密钥吗?": "确定要删除所有已自动禁用的密钥吗?", "确定要删除所选的 {{count}} 个令牌吗?_other": "确定要删除所选的 {{count}} 个令牌吗?", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 2ca78ba..8d2e145 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -24,7 +24,7 @@ "• 需要特定的请求头或认证": "• 需要特定的請求頭或認證", "© {{currentYear}}": "© {{currentYear}}", "| 基于": "| 基於", - "$/1M tokens": "$/1M tokens", + "$/1M tokens": "¥/1M tokens", "0 - 最低": "0 - 最低", "0.002-1之间的小数": "0.002-1之間的小數", "0.1以上的小数": "0.1以上的小數", @@ -339,7 +339,7 @@ "例如:0001": "例如:0001", "例如:1000": "例如:1000", "例如:100000": "例如:100000", - "例如:2,就是最低充值2$": "例如:2,就是最低儲值2$", + "例如:2,就是最低充值2$": "例如:2,就是最低儲值2¥", "例如:2000": "例如:2000", "例如:4.99": "例如:4.99", "例如:7,就是7元/美金": "例如:7,就是7元/美金", @@ -584,7 +584,8 @@ "剩余时间": "剩餘時間", "剩余额度": "剩餘額度", "剩余额度/总额度": "剩餘額度/總額度", - "剩余额度$": "剩餘額度$", + "剩余额度$": "剩餘額度¥", + "剩余额度¥": "剩餘額度¥", "功能特性": "功能特性", "加入渠道": "加入管道", "加入预填组": "加入預填組", @@ -1743,7 +1744,8 @@ "确定清除所有失效兑换码?": "確定清除所有失效兌換碼?", "确定要修改所有子渠道优先级为 ": "確定要修改所有子管道優先級為 ", "确定要修改所有子渠道权重为 ": "確定要修改所有子管道權重為 ", - "确定要充值 $": "確定要儲值 $", + "确定要充值 $": "確定要儲值 ¥", + "确定要充值 ¥": "確定要儲值 ¥", "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "確定要刪除供應商 \"{{name}}\" 嗎?此操作不可撤銷。", "确定要删除所有已自动禁用的密钥吗?": "確定要刪除所有已自動禁用的密鑰嗎?", "确定要删除所选的 {{count}} 个令牌吗?_other": "確定要刪除所選的 {{count}} 個令牌嗎?", diff --git a/web/src/pages/ImageStudio/index.jsx b/web/src/pages/ImageStudio/index.jsx index 6cec66f..25bd8b7 100644 --- a/web/src/pages/ImageStudio/index.jsx +++ b/web/src/pages/ImageStudio/index.jsx @@ -125,6 +125,15 @@ const QUALITY_OPTIONS = [ { value: 'high', label: 'High', description: '更高质量,通常更慢' }, ]; +const DEFAULT_STUDIO_SETTINGS = { + enabled: true, + default_model: 'gpt-image-2', + allow_open_original: true, + retention_minutes: 30, + allowed_models: [], + model_settings: [], +}; + const sortModels = (models = []) => { return [...models].sort((a, b) => { if (a.model_name === 'gpt-image-2') return -1; @@ -159,6 +168,8 @@ const getSizeMeta = (size) => { return SIZE_OPTIONS.find((item) => item.value === size) || SIZE_OPTIONS[0]; }; +const getSizeRatio = (size) => getSizeMeta(size)?.ratioLabel || 'Auto'; + const getImageMimeType = (item) => { const format = String(item?.output_format || '').trim().toLowerCase(); if (format === 'jpeg' || format === 'jpg') { @@ -208,6 +219,8 @@ const normalizeImageResults = (payload) => { .map((item, index) => ({ id: `${Date.now()}-${index}`, src: buildImageSrc(item, urls), + rawUrl: item?.url || '', + taskResultUrl: item?.task_result_url || '', revisedPrompt: item?.revised_prompt || '', downloadName: `gpt-image-${index + 1}.${getImageExtension(item)}`, })) @@ -236,12 +249,14 @@ const ImageStudio = () => { const [submitting, setSubmitting] = useState(false); const [activeTab, setActiveTab] = useState('generate'); const [tokens, setTokens] = useState([]); - const [pricingModels, setPricingModels] = useState([]); + const [imageStudioModels, setImageStudioModels] = useState([]); + const [studioSettings, setStudioSettings] = useState(DEFAULT_STUDIO_SETTINGS); const [selectedTokenId, setSelectedTokenId] = useState(''); const [selectedModel, setSelectedModel] = useState(''); const [prompt, setPrompt] = useState(''); const [selectedSize, setSelectedSize] = useState('auto'); const [selectedQuality, setSelectedQuality] = useState('auto'); + const [selectedRatio, setSelectedRatio] = useState('Auto'); const [referenceImages, setReferenceImages] = useState([]); const [results, setResults] = useState([]); const [resultMeta, setResultMeta] = useState(null); @@ -269,9 +284,9 @@ const ImageStudio = () => { const loadData = async () => { setLoading(true); try { - const [tokensRes, pricingRes] = await Promise.all([ + const [tokensRes, settingsRes] = await Promise.all([ API.get('/api/token/?p=1&size=1000'), - API.get('/api/pricing'), + API.get('/api/image-studio/settings'), ]); const tokenPayload = tokensRes?.data?.data; @@ -283,10 +298,21 @@ const ImageStudio = () => { ); setTokens(imageTokens); - const pricingPayload = Array.isArray(pricingRes?.data?.data) - ? pricingRes.data.data + const nextSettings = settingsRes?.data?.data || DEFAULT_STUDIO_SETTINGS; + const nextModels = Array.isArray(nextSettings?.models) + ? nextSettings.models : []; - setPricingModels(pricingPayload); + setImageStudioModels(nextModels); + setStudioSettings({ + ...DEFAULT_STUDIO_SETTINGS, + ...nextSettings, + allowed_models: Array.isArray(nextSettings?.allowed_models) + ? nextSettings.allowed_models + : [], + model_settings: Array.isArray(nextSettings?.model_settings) + ? nextSettings.model_settings + : [], + }); } catch (error) { showError(t('加载图片制作配置失败,请稍后重试')); } finally { @@ -298,12 +324,14 @@ const ImageStudio = () => { }, [t]); const imageGroupModels = useMemo(() => { - return pricingModels.filter( + return imageStudioModels.filter( (model) => Array.isArray(model?.enable_groups) && - model.enable_groups.includes(IMAGE_GROUP), + model.enable_groups.includes(IMAGE_GROUP) && + (studioSettings.allowed_models.length === 0 || + studioSettings.allowed_models.includes(model.model_name)), ); - }, [pricingModels]); + }, [imageStudioModels, studioSettings.allowed_models]); const endpointType = activeTab === 'edit' ? 'image-edit' : 'image-generation'; @@ -329,6 +357,52 @@ const ImageStudio = () => { return sortModels(matched); }, [endpointModels, selectedToken]); + const selectedModelSetting = useMemo(() => { + return ( + studioSettings.model_settings.find((item) => item?.model === selectedModel) || + null + ); + }, [selectedModel, studioSettings.model_settings]); + + const ratioOptions = useMemo(() => { + const sizes = + Array.isArray(selectedModelSetting?.allowed_sizes) && + selectedModelSetting.allowed_sizes.length > 0 + ? selectedModelSetting.allowed_sizes + : SIZE_OPTIONS.map((item) => item.value); + const seen = new Set(); + return sizes + .map((size) => getSizeRatio(size)) + .filter((ratio) => { + if (seen.has(ratio)) return false; + seen.add(ratio); + return true; + }) + .map((ratio) => ({ label: ratio, value: ratio })); + }, [selectedModelSetting]); + + const sizeRatioOptions = useMemo(() => { + return ratioOptions.map((item) => ({ + label: + item.value === 'Auto' + ? 'Auto' + : `${item.value} · ${t('按该比例筛选可选分辨率')}`, + value: item.value, + })); + }, [ratioOptions, t]); + + const resolutionCandidates = useMemo(() => { + const allowed = + Array.isArray(selectedModelSetting?.allowed_sizes) && + selectedModelSetting.allowed_sizes.length > 0 + ? selectedModelSetting.allowed_sizes + : SIZE_OPTIONS.map((item) => item.value); + return allowed.filter((size) => { + if (selectedRatio === 'Auto') return size === 'auto'; + return getSizeRatio(size) === selectedRatio; + }); + }, [selectedModelSetting, selectedRatio]); + useEffect(() => { if (!tokens.length) { if (selectedTokenId) { @@ -355,11 +429,12 @@ const ImageStudio = () => { } const preferredModel = + availableModels.find((model) => model.model_name === studioSettings.default_model) || availableModels.find((model) => model.model_name === 'gpt-image-2') || availableModels[0]; setSelectedModel(preferredModel?.model_name || ''); - }, [availableModels, selectedModel]); + }, [availableModels, selectedModel, studioSettings.default_model]); const tokenOptions = useMemo(() => { return tokens.map((token) => ({ @@ -375,35 +450,36 @@ const ImageStudio = () => { })); }, [availableModels]); - const sizeRatioOptions = useMemo(() => { - return SIZE_OPTIONS.map((item) => ({ - label: - item.value === 'auto' - ? 'Auto' - : `${item.ratioLabel} · ${item.usage}`, - value: item.value, - })); - }, []); - const sizeResolutionOptions = useMemo(() => { - return SIZE_OPTIONS.map((item) => ({ - label: - item.value === 'auto' - ? 'Auto(默认)' - : `${item.resolutionLabel} · 官方预设`, - value: item.value, - })); - }, []); + return resolutionCandidates.map((value) => { + const item = getSizeMeta(value); + return { + label: + item.value === 'auto' + ? 'Auto(默认)' + : `${item.resolutionLabel} · 官方预设`, + value: item.value, + }; + }); + }, [resolutionCandidates]); const qualityOptions = useMemo(() => { - return QUALITY_OPTIONS.map((item) => ({ + const allowed = + Array.isArray(selectedModelSetting?.allowed_qualities) && + selectedModelSetting.allowed_qualities.length > 0 + ? selectedModelSetting.allowed_qualities + : QUALITY_OPTIONS.map((item) => item.value); + return QUALITY_OPTIONS.filter((item) => allowed.includes(item.value)).map((item) => ({ label: `${item.label} · ${item.description}`, value: item.value, })); - }, []); + }, [selectedModelSetting]); const sizeMeta = getSizeMeta(selectedSize); const tokenModelLimits = parseTokenModelLimits(selectedToken); + const allowRatioSelection = selectedModelSetting?.allow_ratio !== false; + const allowResolutionSelection = selectedModelSetting?.allow_resolution !== false; + const allowQualitySelection = selectedModelSetting?.allow_quality !== false; const noImageTokens = !loading && tokens.length === 0; const noImageModels = !loading && imageGroupModels.length === 0; @@ -414,8 +490,57 @@ const ImageStudio = () => { !!selectedToken && availableModels.length === 0; + useEffect(() => { + if (selectedModelSetting?.default_size) { + setSelectedSize(selectedModelSetting.default_size); + setSelectedRatio(getSizeRatio(selectedModelSetting.default_size)); + return; + } + if (!selectedSize) { + setSelectedSize('auto'); + setSelectedRatio('Auto'); + } + }, [selectedModel, selectedModelSetting]); + + useEffect(() => { + if (!allowRatioSelection) { + const nextRatio = + getSizeRatio(selectedModelSetting?.default_size || resolutionCandidates[0] || 'auto') || + 'Auto'; + if (selectedRatio !== nextRatio) { + setSelectedRatio(nextRatio); + } + } + }, [allowRatioSelection, resolutionCandidates, selectedModelSetting, selectedRatio]); + + useEffect(() => { + if (!resolutionCandidates.length) { + setSelectedSize('auto'); + return; + } + if (!resolutionCandidates.includes(selectedSize)) { + setSelectedSize(resolutionCandidates[0]); + } + }, [resolutionCandidates, selectedSize]); + + useEffect(() => { + const allowedValues = qualityOptions.map((item) => item.value); + if (!allowedValues.length) { + setSelectedQuality('auto'); + return; + } + if (!allowedValues.includes(selectedQuality)) { + setSelectedQuality( + selectedModelSetting?.default_quality && allowedValues.includes(selectedModelSetting.default_quality) + ? selectedModelSetting.default_quality + : allowedValues[0], + ); + } + }, [qualityOptions, selectedModelSetting, selectedQuality]); + const canSubmit = !submitting && + studioSettings.enabled && !!selectedTokenId && !!selectedModel && prompt.trim().length > 0 && @@ -602,6 +727,10 @@ const ImageStudio = () => { }; const handleSubmit = async () => { + if (!studioSettings.enabled) { + showError(t('图片制作功能暂未开放')); + return; + } if (!selectedTokenId) { showError(t('请先选择 image 分组令牌')); return; @@ -704,7 +833,11 @@ const ImageStudio = () => { {t('模型优先默认 gpt-image-2')} - {t('结果为临时图片,约 30 分钟后自动删除')} + + {t('结果为临时图片,约 {{minutes}} 分钟后自动删除', { + minutes: studioSettings.retention_minutes || 30, + })} + @@ -1028,12 +1161,17 @@ const ImageStudio = () => {

{t('比例')}