From 8ee89478a96c2969b26b891776735d1fd26dc449 Mon Sep 17 00:00:00 2001
From: ikmkj
Date: Tue, 5 May 2026 15:27:51 +0800
Subject: [PATCH] Align console behavior with operator routing and image
workflow expectations
Constraint: Must preserve current exchange-rate logic, same-group routing boundary, and temporary image-file behavior while fixing UX-visible inconsistencies.
Rejected: Large frontend chunk refactor | introduced risky circular chunking and did not improve deliverability for this rollout.
Confidence: medium
Scope-risk: moderate
Directive: Keep channel failover constrained to the selected group and preserve Beijing-day semantics for user cache dashboard metrics.
Tested: go test ./controller ./model ./service ./common -count=1; npm run build; remote smoke on source deployment at http://38.76.218.56:18081/ plus /api/status and /api/image-studio/settings.
Not-tested: Browser-driven end-to-end verification of every console page interaction.
---
Dockerfile | 8 +-
controller/image_studio.go | 410 +++++++++++++++++-
controller/option.go | 26 ++
model/ability.go | 22 +-
model/channel_cache.go | 85 ++--
model/channel_cache_selection_test.go | 57 +++
model/log.go | 26 +-
model/log_dashboard_metrics_test.go | 40 ++
model/option.go | 6 +
router/api-router.go | 1 +
service/channel_select.go | 16 +-
web/package.json | 1 +
web/src/App.jsx | 8 +-
web/src/components/dashboard/index.jsx | 15 +-
.../components/settings/DrawingSetting.jsx | 4 +
.../personal/cards/NotificationSettings.jsx | 10 +-
.../table/channels/ChannelsColumnDefs.jsx | 12 +-
.../channels/modals/EditChannelModal.jsx | 8 +-
.../modals/ViewDetailsModal.jsx | 2 +-
.../filter/PricingDisplaySettings.jsx | 4 +-
.../modals/EditRedemptionModal.jsx | 12 +-
.../table/tokens/modals/EditTokenModal.jsx | 12 +-
web/src/components/topup/RechargeCard.jsx | 71 +--
web/src/components/topup/index.jsx | 4 +-
.../topup/modals/TopupHistoryModal.jsx | 2 +-
web/src/helpers/render.jsx | 18 +-
web/src/helpers/utils.jsx | 4 +-
web/src/hooks/channels/useChannelsData.jsx | 4 +-
web/src/hooks/dashboard/useDashboardData.js | 8 +-
.../model-pricing/useModelPricingData.jsx | 2 +-
web/src/i18n/locales/en.json | 8 +-
web/src/i18n/locales/fr.json | 8 +-
web/src/i18n/locales/ja.json | 8 +-
web/src/i18n/locales/ru.json | 8 +-
web/src/i18n/locales/vi.json | 8 +-
web/src/i18n/locales/zh-CN.json | 10 +-
web/src/i18n/locales/zh-TW.json | 10 +-
web/src/pages/ImageStudio/index.jsx | 240 ++++++++--
.../Setting/Drawing/SettingsImageStudio.jsx | 396 +++++++++++++++++
.../Setting/Operation/SettingsGeneral.jsx | 4 +-
.../Payment/SettingsPaymentGateway.jsx | 2 +-
.../Payment/SettingsPaymentGatewayCreem.jsx | 2 +-
.../Payment/SettingsPaymentGatewayStripe.jsx | 2 +-
.../Ratio/components/ModelPricingEditor.jsx | 16 +-
.../Ratio/hooks/useModelPricingEditorState.js | 6 +-
web/vite.config.js | 4 +
46 files changed, 1341 insertions(+), 289 deletions(-)
create mode 100644 model/channel_cache_selection_test.go
create mode 100644 model/log_dashboard_metrics_test.go
create mode 100644 web/src/pages/Setting/Drawing/SettingsImageStudio.jsx
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('比例')}
@@ -1042,9 +1180,14 @@ const ImageStudio = () => {
value={selectedSize}
optionList={sizeResolutionOptions}
onChange={(value) => setSelectedSize(String(value || 'auto'))}
- disabled={submitting}
+ disabled={submitting || !allowResolutionSelection}
style={{ width: '100%' }}
/>
+ {!allowResolutionSelection && (
+
+ {t('当前模型已固定分辨率,不允许在图片制作中手动切换。')}
+
+ )}
@@ -1055,9 +1198,14 @@ const ImageStudio = () => {
value={selectedQuality}
optionList={qualityOptions}
onChange={(value) => setSelectedQuality(String(value || 'auto'))}
- disabled={submitting}
+ disabled={submitting || !allowQualitySelection}
style={{ width: '100%' }}
/>
+ {!allowQualitySelection && (
+
+ {t('当前模型已固定质量,不允许在图片制作中手动切换。')}
+
+ )}
@@ -1228,16 +1376,18 @@ const ImageStudio = () => {
>
{t('预览图片')}
+ {studioSettings.allow_open_original && (
+
+ {t('打开原图')}
+
+ )}
- {t('打开原图')}
-
-
diff --git a/web/src/pages/Setting/Drawing/SettingsImageStudio.jsx b/web/src/pages/Setting/Drawing/SettingsImageStudio.jsx
new file mode 100644
index 0000000..bb5a55e
--- /dev/null
+++ b/web/src/pages/Setting/Drawing/SettingsImageStudio.jsx
@@ -0,0 +1,396 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ Button,
+ Col,
+ Form,
+ Row,
+ Spin,
+ Tag,
+ Typography,
+} from '@douyinfe/semi-ui';
+import { API, compareObjects, showError, showSuccess, showWarning } from '../../../helpers';
+import { useTranslation } from 'react-i18next';
+
+const { Text } = Typography;
+
+const DEFAULT_SETTINGS = {
+ enabled: true,
+ default_model: 'gpt-image-2',
+ allow_open_original: true,
+ retention_minutes: 30,
+ allowed_models: [],
+ model_settings: [],
+};
+
+const SIZE_OPTIONS = [
+ 'auto',
+ '1024x1024',
+ '1536x1024',
+ '1024x1536',
+ '2048x2048',
+ '2048x1152',
+ '1152x2048',
+ '3840x2160',
+ '2160x3840',
+];
+
+const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
+
+const normalizeSettings = (value) => {
+ const next = { ...DEFAULT_SETTINGS, ...(value || {}) };
+ next.allowed_models = Array.isArray(next.allowed_models)
+ ? next.allowed_models.filter(Boolean)
+ : [];
+ next.model_settings = Array.isArray(next.model_settings)
+ ? next.model_settings.map((item) => ({
+ model: item?.model || '',
+ enabled: item?.enabled !== false,
+ allow_generate: item?.allow_generate !== false,
+ allow_edit: !!item?.allow_edit,
+ allow_ratio: item?.allow_ratio !== false,
+ allow_resolution: item?.allow_resolution !== false,
+ allow_quality: item?.allow_quality !== false,
+ allowed_sizes: Array.isArray(item?.allowed_sizes)
+ ? item.allowed_sizes.filter(Boolean)
+ : ['auto'],
+ allowed_qualities: Array.isArray(item?.allowed_qualities)
+ ? item.allowed_qualities.filter(Boolean)
+ : ['auto', 'low', 'medium', 'high'],
+ default_size: item?.default_size || 'auto',
+ default_quality: item?.default_quality || 'auto',
+ }))
+ : [];
+ return next;
+};
+
+export default function SettingsImageStudio({ options, refresh }) {
+ const { t } = useTranslation();
+ const refForm = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const [inputs, setInputs] = useState(DEFAULT_SETTINGS);
+ const [inputsRow, setInputsRow] = useState(DEFAULT_SETTINGS);
+
+ const pricingModels = useMemo(() => {
+ try {
+ return JSON.parse(options?.ImageStudioAvailableModels || '[]');
+ } catch {
+ return [];
+ }
+ }, [options?.ImageStudioAvailableModels]);
+
+ const modelOptions = useMemo(
+ () =>
+ pricingModels.map((modelName) => ({
+ label: modelName,
+ value: modelName,
+ })),
+ [pricingModels],
+ );
+
+ const saveSettings = async () => {
+ const updateArray = compareObjects(inputs, inputsRow);
+ if (!updateArray.length) {
+ return showWarning(t('你似乎并没有修改什么'));
+ }
+ setLoading(true);
+ try {
+ await API.put('/api/option/', {
+ key: 'ImageStudioSettings',
+ value: JSON.stringify(inputs),
+ });
+ await API.put('/api/option/', {
+ key: 'ImageStudioRetentionMinutes',
+ value: String(inputs.retention_minutes || 30),
+ });
+ showSuccess(t('保存成功'));
+ refresh();
+ } catch (error) {
+ showError(t('保存失败,请重试'));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ const raw = options?.ImageStudioSettings;
+ let parsed = DEFAULT_SETTINGS;
+ if (raw) {
+ try {
+ parsed = normalizeSettings(JSON.parse(raw));
+ } catch {
+ parsed = DEFAULT_SETTINGS;
+ }
+ }
+ if (options?.ImageStudioRetentionMinutes) {
+ parsed.retention_minutes = Number(options.ImageStudioRetentionMinutes) || 30;
+ }
+ setInputs(parsed);
+ setInputsRow(structuredClone(parsed));
+ refForm.current?.setValues(parsed);
+ }, [options]);
+
+ const updateField = (key, value) => {
+ setInputs((prev) => ({ ...prev, [key]: value }));
+ };
+
+ const updateModelSetting = (modelName, patch) => {
+ setInputs((prev) => {
+ const current = [...prev.model_settings];
+ const index = current.findIndex((item) => item.model === modelName);
+ if (index >= 0) {
+ current[index] = { ...current[index], ...patch };
+ } else {
+ current.push(
+ normalizeSettings({
+ model_settings: [{ model: modelName, ...patch }],
+ }).model_settings[0],
+ );
+ }
+ return { ...prev, model_settings: current };
+ });
+ };
+
+ const selectedModelSettings = useMemo(() => {
+ const result = {};
+ inputs.model_settings.forEach((item) => {
+ result[item.model] = item;
+ });
+ return result;
+ }, [inputs.model_settings]);
+
+ return (
+
+
+
+
+ updateField('enabled', value)}
+ />
+
+
+ updateField('allow_open_original', value)}
+ />
+
+
+ updateField('retention_minutes', Number(value || 30))}
+ />
+
+
+
+
+
+ updateField('default_model', String(value || ''))}
+ />
+
+
+ updateField('allowed_models', Array.isArray(value) ? value : [])}
+ />
+
+
+
+
+
{t('按模型控制参数')}
+
+ {inputs.allowed_models.map((modelName) => {
+ const setting = selectedModelSettings[modelName] || {
+ model: modelName,
+ enabled: true,
+ allow_generate: true,
+ allow_edit: false,
+ allow_ratio: true,
+ allow_resolution: true,
+ allow_quality: true,
+ allowed_sizes: ['auto'],
+ allowed_qualities: ['auto', 'low', 'medium', 'high'],
+ default_size: 'auto',
+ default_quality: 'auto',
+ };
+ return (
+
+
+ {modelName}
+
+
+
+ updateModelSetting(modelName, { enabled: value })}
+ />
+
+
+
+ updateModelSetting(modelName, { allow_generate: value })
+ }
+ />
+
+
+ updateModelSetting(modelName, { allow_edit: value })}
+ />
+
+
+
+ updateModelSetting(modelName, { allow_quality: value })
+ }
+ />
+
+
+
+
+
+ updateModelSetting(modelName, { allow_ratio: value })
+ }
+ />
+
+
+
+ updateModelSetting(modelName, { allow_resolution: value })
+ }
+ />
+
+
+
+
+ ({ label: item, value: item }))}
+ initValue={setting.allowed_sizes}
+ onChange={(value) =>
+ updateModelSetting(modelName, {
+ allowed_sizes: Array.isArray(value) ? value : [],
+ })
+ }
+ />
+
+
+ ({ label: item, value: item }))}
+ initValue={setting.allowed_qualities}
+ onChange={(value) =>
+ updateModelSetting(modelName, {
+ allowed_qualities: Array.isArray(value) ? value : [],
+ })
+ }
+ />
+
+
+
+
+ ({ label: item, value: item }))}
+ initValue={setting.default_size}
+ onChange={(value) =>
+ updateModelSetting(modelName, { default_size: String(value || 'auto') })
+ }
+ />
+
+
+ ({ label: item, value: item }))}
+ initValue={setting.default_quality}
+ onChange={(value) =>
+ updateModelSetting(modelName, {
+ default_quality: String(value || 'auto'),
+ })
+ }
+ />
+
+
+
+ );
+ })}
+
+
+
+
+
+ {t('说明:这里只控制图片制作页面可见和可选参数,不影响 image 分组本身的渠道与模型能力配置。')}
+
+
+
+ {t('建议:先确定允许模型,再按模型限制生成/编辑、比例、分辨率、质量和默认值,避免前后端参数不一致。')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Setting/Operation/SettingsGeneral.jsx b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
index 8b9a621..1b76d95 100644
--- a/web/src/pages/Setting/Operation/SettingsGeneral.jsx
+++ b/web/src/pages/Setting/Operation/SettingsGeneral.jsx
@@ -220,8 +220,8 @@ export default function GeneralSettings(props) {
'general_setting.quota_display_type',
)}
>
- USD ($)
- CNY (¥)
+ USD (¥)
+ CNY (¥)
Tokens
{t('自定义货币')}
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
index a4f1029..2c48a78 100644
--- a/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
+++ b/web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
@@ -269,7 +269,7 @@ export default function SettingsPaymentGateway(props) {
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
index 41de8d2..b1213eb 100644
--- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
+++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.jsx
@@ -221,7 +221,7 @@ export default function SettingsPaymentGatewayCreem(props) {
dataIndex: 'price',
key: 'price',
render: (price, record) =>
- `${record.currency === 'EUR' ? '€' : '$'}${price}`,
+ `${record.currency === 'EUR' ? '€' : '¥'}${price}`,
},
{
title: t('充值额度'),
diff --git a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx
index e4ddea1..30b89cc 100644
--- a/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx
+++ b/web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.jsx
@@ -237,7 +237,7 @@ export default function SettingsPaymentGateway(props) {
diff --git a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
index 5028a3f..2671366 100644
--- a/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
+++ b/web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
@@ -411,7 +411,7 @@ export default function ModelPricingEditor({
label={t('固定价格')}
value={selectedModel.fixedPrice}
placeholder={t('输入每次调用价格')}
- suffix={t('$/次')}
+ suffix={t('¥/次')}
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
extraText={t('适合 MJ / 任务类等按次收费模型。')}
/>
@@ -428,7 +428,7 @@ export default function ModelPricingEditor({
handleNumericFieldChange('inputPrice', value)}
/>
{selectedModel.completionRatioLocked ? (
@@ -450,7 +450,7 @@ export default function ModelPricingEditor({
handleNumericFieldChange('completionPrice', value)
}
@@ -493,7 +493,7 @@ export default function ModelPricingEditor({
handleNumericFieldChange('cachePrice', value)}
headerAction={
handleNumericFieldChange('createCachePrice', value)
}
@@ -562,7 +562,7 @@ export default function ModelPricingEditor({
handleNumericFieldChange('imagePrice', value)}
headerAction={
handleNumericFieldChange('audioInputPrice', value)
}
@@ -614,7 +614,7 @@ export default function ModelPricingEditor({
handleNumericFieldChange('audioOutputPrice', value)
}
diff --git a/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js b/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
index 2f224fa..4c47efa 100644
--- a/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
+++ b/web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { API, showError, showSuccess } from '../../../../helpers';
export const PAGE_SIZE = 10;
-export const PRICE_SUFFIX = '$/1M tokens';
+export const PRICE_SUFFIX = '¥/1M tokens';
const EMPTY_CANDIDATE_MODEL_NAMES = [];
const EMPTY_MODEL = {
@@ -245,7 +245,7 @@ export const getModelWarnings = (model, t) => {
export const buildSummaryText = (model, t) => {
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
- return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
+ return `${t('按次')} ¥${model.fixedPrice} / ${t('次')}`;
}
if (hasValue(model.inputPrice)) {
@@ -259,7 +259,7 @@ export const buildSummaryText = (model, t) => {
].filter(hasValue).length;
const extraLabel =
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
- return `${t('输入')} $${model.inputPrice}${extraLabel}`;
+ return `${t('输入')} ¥${model.inputPrice}${extraLabel}`;
}
return t('未设置价格');
diff --git a/web/vite.config.js b/web/vite.config.js
index 73e4621..30d9f65 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -29,6 +29,10 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+ '@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
+ __dirname,
+ './node_modules/@douyinfe/semi-ui/dist/css/semi.css',
+ ),
},
},
plugins: [