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.
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY web/package.json .
|
COPY web/package.json .
|
||||||
COPY web/bun.lock .
|
COPY web/package-lock.json .
|
||||||
RUN bun install
|
RUN npm ci --legacy-peer-deps
|
||||||
COPY ./web .
|
COPY ./web .
|
||||||
COPY ./VERSION .
|
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
|
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||||
ENV GO111MODULE=on CGO_ENABLED=0
|
ENV GO111MODULE=on CGO_ENABLED=0
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/textproto"
|
"net/textproto"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,7 +34,6 @@ const (
|
|||||||
imageStudioTaskStatusRunning = "running"
|
imageStudioTaskStatusRunning = "running"
|
||||||
imageStudioTaskStatusSuccess = "success"
|
imageStudioTaskStatusSuccess = "success"
|
||||||
imageStudioTaskStatusFailure = "failure"
|
imageStudioTaskStatusFailure = "failure"
|
||||||
imageStudioTaskTTL = 30 * time.Minute
|
|
||||||
imageStudioTaskCleanupEvery = 5 * time.Minute
|
imageStudioTaskCleanupEvery = 5 * time.Minute
|
||||||
imageStudioHTTPTimeout = 10 * time.Minute
|
imageStudioHTTPTimeout = 10 * time.Minute
|
||||||
imageStudioGenerateAction = "generate"
|
imageStudioGenerateAction = "generate"
|
||||||
@@ -71,21 +71,325 @@ type imageStudioTask struct {
|
|||||||
UpdatedAt int64 `json:"updated_at"`
|
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 (
|
var (
|
||||||
imageStudioTasks = struct {
|
imageStudioTasks = struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
items map[string]*imageStudioTask
|
items map[string]*imageStudioTask
|
||||||
}{items: make(map[string]*imageStudioTask)}
|
}{items: make(map[string]*imageStudioTask)}
|
||||||
imageStudioCleanupOnce sync.Once
|
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() {
|
func ensureImageStudioTaskCleanup() {
|
||||||
imageStudioCleanupOnce.Do(func() {
|
imageStudioCleanupOnce.Do(func() {
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(imageStudioTaskCleanupEvery)
|
ticker := time.NewTicker(imageStudioTaskCleanupEvery)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
cutoff := time.Now().Add(-imageStudioTaskTTL).Unix()
|
cutoff := time.Now().Add(-getImageStudioTaskTTL()).Unix()
|
||||||
imageStudioTasks.Lock()
|
imageStudioTasks.Lock()
|
||||||
for id, task := range imageStudioTasks.items {
|
for id, task := range imageStudioTasks.items {
|
||||||
if task.UpdatedAt < cutoff {
|
if task.UpdatedAt < cutoff {
|
||||||
@@ -410,6 +714,50 @@ func validateImageStudioTokenAndModel(userID int, req imageStudioGenerateRequest
|
|||||||
if modelName == "" {
|
if modelName == "" {
|
||||||
return nil, fmt.Errorf("请选择图片模型")
|
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))) {
|
if isGPTImage2Model(modelName) && !isValidGPTImage2Size(strings.TrimSpace(strings.ToLower(req.Size))) {
|
||||||
return nil, fmt.Errorf("gpt-image-2 尺寸不合法:最长边不能超过 3840,边长需为 16 的倍数,长宽比不能超过 3:1,总像素需在 655360 到 8294400 之间")
|
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 {
|
func buildImageStudioResultFileURL(taskID string, index int, outputFormat string) string {
|
||||||
relativePath := fmt.Sprintf("%s/%d.%s", taskID, index, getImageStudioResultExtension(outputFormat))
|
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)
|
signature := signImageStudioResult(relativePath, expiresAt)
|
||||||
return fmt.Sprintf("%s/%s?expires=%d&sig=%s", imageStudioResultURLPrefix, relativePath, expiresAt, signature)
|
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)
|
common.ApiSuccess(c, task)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetImageStudioSettings(c *gin.Context) {
|
||||||
|
common.ApiSuccess(c, buildImageStudioPublicSettings())
|
||||||
|
}
|
||||||
|
|
||||||
func buildImageStudioTaskResultURL(taskID string, index int) string {
|
func buildImageStudioTaskResultURL(taskID string, index int) string {
|
||||||
return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index)
|
return fmt.Sprintf("/api/image-studio/task/%s/result/%d", taskID, index)
|
||||||
}
|
}
|
||||||
@@ -962,11 +1314,18 @@ func sanitizeImageStudioTaskForResponse(task *imageStudioTask) *imageStudioTask
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
taskResultURL := buildImageStudioTaskResultURL(task.ID, index)
|
||||||
if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" {
|
if b64, _ := item["b64_json"].(string); strings.TrimSpace(b64) != "" {
|
||||||
delete(item, "b64_json")
|
delete(item, "b64_json")
|
||||||
item["url"] = buildImageStudioTaskResultURL(task.ID, index)
|
|
||||||
changed = true
|
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 {
|
if !changed {
|
||||||
return &clone
|
return &clone
|
||||||
@@ -1028,6 +1387,27 @@ func GetImageStudioTaskResult(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "图片结果不存在")
|
common.ApiErrorMsg(c, "图片结果不存在")
|
||||||
return
|
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) != "" {
|
if url, _ := item["url"].(string); strings.TrimSpace(url) != "" {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, url)
|
c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -89,6 +89,32 @@ func GetOptions(c *gin.Context) {
|
|||||||
Key: "CompletionRatioMeta",
|
Key: "CompletionRatioMeta",
|
||||||
Value: buildCompletionRatioMetaValue(optionValues),
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "",
|
"message": "",
|
||||||
|
|||||||
@@ -111,31 +111,13 @@ func GetChannel(group string, model string, retry int) (*Channel, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if common.UsingSQLite || common.UsingPostgreSQL {
|
err = channelQuery.Order("weight DESC, channel_id ASC").Find(&abilities).Error
|
||||||
err = channelQuery.Order("weight DESC").Find(&abilities).Error
|
|
||||||
} else {
|
|
||||||
err = channelQuery.Order("weight DESC").Find(&abilities).Error
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
channel := Channel{}
|
channel := Channel{}
|
||||||
if len(abilities) > 0 {
|
if len(abilities) > 0 {
|
||||||
// Randomly choose one
|
channel.Id = abilities[0].ChannelId
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -115,13 +113,6 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
|
|||||||
return nil, nil
|
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)
|
uniquePriorities := make(map[int]bool)
|
||||||
for _, channelId := range channels {
|
for _, channelId := range channels {
|
||||||
if channel, ok := channelsIDM[channelId]; ok {
|
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)))
|
sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities)))
|
||||||
|
|
||||||
if retry >= len(uniquePriorities) {
|
if len(sortedUniquePriorities) == 0 {
|
||||||
retry = len(uniquePriorities) - 1
|
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
|
orderedChannels := make([]*Channel, 0, len(channels))
|
||||||
var sumWeight = 0
|
for _, priority := range sortedUniquePriorities {
|
||||||
var targetChannels []*Channel
|
targetPriority := int64(priority)
|
||||||
|
targetChannels := make([]*Channel, 0)
|
||||||
for _, channelId := range channels {
|
for _, channelId := range channels {
|
||||||
if channel, ok := channelsIDM[channelId]; ok {
|
channel, ok := channelsIDM[channelId]
|
||||||
if channel.GetPriority() == targetPriority {
|
if !ok {
|
||||||
sumWeight += channel.GetWeight()
|
|
||||||
targetChannels = append(targetChannels, channel)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
|
return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId)
|
||||||
}
|
}
|
||||||
}
|
if channel.GetPriority() == targetPriority {
|
||||||
|
targetChannels = append(targetChannels, channel)
|
||||||
if len(targetChannels) == 0 {
|
|
||||||
return nil, errors.New(fmt.Sprintf("no channel found, group: %s, model: %s, priority: %d", group, model, targetPriority))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
sort.SliceStable(targetChannels, func(i, j int) bool {
|
||||||
return nil, errors.New("channel not found")
|
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(orderedChannels) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if retry >= len(orderedChannels) {
|
||||||
|
retry = len(orderedChannels) - 1
|
||||||
|
}
|
||||||
|
return orderedChannels[retry], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CacheGetChannel(id int) (*Channel, error) {
|
func CacheGetChannel(id int) (*Channel, error) {
|
||||||
|
|||||||
57
model/channel_cache_selection_test.go
Normal file
57
model/channel_cache_selection_test.go
Normal file
@@ -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 }
|
||||||
20
model/log.go
20
model/log.go
@@ -223,8 +223,6 @@ type CacheDashboardMetrics struct {
|
|||||||
type cacheMetricRow struct {
|
type cacheMetricRow struct {
|
||||||
CreatedAt int64 `gorm:"column:created_at"`
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
PromptTokens int `gorm:"column:prompt_tokens"`
|
||||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
|
||||||
ModelName string `gorm:"column:model_name"`
|
|
||||||
Other string `gorm:"column:other"`
|
Other string `gorm:"column:other"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
func GetUserCacheDashboardMetrics(userId int) (CacheDashboardMetrics, CacheDashboardMetrics, error) {
|
||||||
now := time.Now()
|
start := getDashboardDayStartUnix(time.Now())
|
||||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Unix()
|
|
||||||
overall := CacheDashboardMetrics{}
|
overall := CacheDashboardMetrics{}
|
||||||
today := CacheDashboardMetrics{}
|
today := CacheDashboardMetrics{}
|
||||||
var rows []cacheMetricRow
|
var rows []cacheMetricRow
|
||||||
err := LOG_DB.Model(&Log{}).
|
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).
|
Where("user_id = ? AND type = ?", userId, LogTypeConsume).
|
||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
FindInBatches(&rows, 1000, func(tx *gorm.DB, batch int) error {
|
FindInBatches(&rows, 1000, func(tx *gorm.DB, batch int) error {
|
||||||
|
|||||||
40
model/log_dashboard_metrics_test.go
Normal file
40
model/log_dashboard_metrics_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,8 @@ func InitOptionMap() {
|
|||||||
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
|
common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString()
|
||||||
common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString()
|
common.OptionMap["ImageStudioSafetyPrompt"] = setting.ImageStudioSafetyPromptToString()
|
||||||
common.OptionMap["ImageStudioBannedWords"] = setting.ImageStudioBannedWordsToString()
|
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["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength)
|
||||||
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
|
common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString()
|
||||||
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
|
common.OptionMap["AutomaticDisableStatusCodes"] = operation_setting.AutomaticDisableStatusCodesToString()
|
||||||
@@ -503,6 +505,10 @@ func updateOptionMap(key string, value string) (err error) {
|
|||||||
setting.ImageStudioSafetyPromptFromString(value)
|
setting.ImageStudioSafetyPromptFromString(value)
|
||||||
case "ImageStudioBannedWords":
|
case "ImageStudioBannedWords":
|
||||||
setting.ImageStudioBannedWordsFromString(value)
|
setting.ImageStudioBannedWordsFromString(value)
|
||||||
|
case "ImageStudioSettings":
|
||||||
|
// 由控制器/前端按 JSON 读取;这里仅保留在 OptionMap 中供运行时使用。
|
||||||
|
case "ImageStudioRetentionMinutes":
|
||||||
|
// 由图片制作运行时读取 OptionMap 并解析,避免再引入重复全局状态。
|
||||||
case "AutomaticDisableKeywords":
|
case "AutomaticDisableKeywords":
|
||||||
operation_setting.AutomaticDisableKeywordsFromString(value)
|
operation_setting.AutomaticDisableKeywordsFromString(value)
|
||||||
case "AutomaticDisableStatusCodes":
|
case "AutomaticDisableStatusCodes":
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
imageStudioRoute := apiRouter.Group("/image-studio")
|
imageStudioRoute := apiRouter.Group("/image-studio")
|
||||||
imageStudioRoute.Use(middleware.UserAuth())
|
imageStudioRoute.Use(middleware.UserAuth())
|
||||||
{
|
{
|
||||||
|
imageStudioRoute.GET("/settings", controller.GetImageStudioSettings)
|
||||||
imageStudioRoute.POST("/generate", controller.SubmitImageStudioGenerate)
|
imageStudioRoute.POST("/generate", controller.SubmitImageStudioGenerate)
|
||||||
imageStudioRoute.POST("/edit", controller.SubmitImageStudioEdit)
|
imageStudioRoute.POST("/edit", controller.SubmitImageStudioEdit)
|
||||||
imageStudioRoute.GET("/task/:id", controller.GetImageStudioTask)
|
imageStudioRoute.GET("/task/:id", controller.GetImageStudioTask)
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ func (p *RetryParam) ResetRetryNextTry() {
|
|||||||
p.resetNextTry = true
|
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:
|
// For "auto" tokenGroup with cross-group Retry enabled:
|
||||||
// 对于启用了跨分组重试的 "auto" tokenGroup:
|
// 对于启用了跨分组重试的 "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.
|
// - Uses ContextKeyAutoGroupIndex to track current group index.
|
||||||
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。
|
// 使用 ContextKeyAutoGroupIndex 跟踪当前分组索引。
|
||||||
@@ -60,11 +60,11 @@ func (p *RetryParam) ResetRetryNextTry() {
|
|||||||
// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started.
|
// - Uses ContextKeyAutoGroupRetryIndex to track the global Retry count when current group started.
|
||||||
// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。
|
// 使用 ContextKeyAutoGroupRetryIndex 跟踪当前分组开始时的全局重试次数。
|
||||||
//
|
//
|
||||||
// - priorityRetry = Retry - startRetryIndex, represents the priority level within current group.
|
// - priorityRetry = Retry - startRetryIndex, represents the channel attempt index within current group.
|
||||||
// priorityRetry = Retry - startRetryIndex,表示当前分组内的优先级级别。
|
// priorityRetry = Retry - startRetryIndex,表示当前分组内的候选渠道尝试序号。
|
||||||
//
|
//
|
||||||
// - When GetRandomSatisfiedChannel returns nil (priorities exhausted), moves to next group.
|
// - When GetRandomSatisfiedChannel returns nil (group exhausted), moves to next group.
|
||||||
// 当 GetRandomSatisfiedChannel 返回 nil(优先级用完)时,切换到下一个分组。
|
// 当 GetRandomSatisfiedChannel 返回 nil(当前分组候选已用尽)时,切换到下一个分组。
|
||||||
//
|
//
|
||||||
// Example flow (2 groups, each with 2 priorities, RetryTimes=3):
|
// Example flow (2 groups, each with 2 priorities, RetryTimes=3):
|
||||||
// 示例流程(2个分组,每个有2个优先级,RetryTimes=3):
|
// 示例流程(2个分组,每个有2个优先级,RetryTimes=3):
|
||||||
|
|||||||
1
web/package.json
vendored
1
web/package.json
vendored
@@ -10,6 +10,7 @@
|
|||||||
"@visactor/react-vchart": "~1.8.8",
|
"@visactor/react-vchart": "~1.8.8",
|
||||||
"@visactor/vchart": "~1.8.8",
|
"@visactor/vchart": "~1.8.8",
|
||||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||||
|
"antd": "^5.29.3",
|
||||||
"axios": "1.13.5",
|
"axios": "1.13.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
|
|||||||
6
web/src/App.jsx
vendored
6
web/src/App.jsx
vendored
@@ -43,11 +43,9 @@ import Pricing from './pages/Pricing';
|
|||||||
import Task from './pages/Task';
|
import Task from './pages/Task';
|
||||||
import ModelPage from './pages/Model';
|
import ModelPage from './pages/Model';
|
||||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||||
import Playground from './pages/Playground';
|
|
||||||
import Subscription from './pages/Subscription';
|
import Subscription from './pages/Subscription';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
import PersonalSetting from './components/settings/PersonalSetting';
|
||||||
import ImageStudio from './pages/ImageStudio';
|
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
import SetupCheck from './components/layout/SetupCheck';
|
import SetupCheck from './components/layout/SetupCheck';
|
||||||
|
|
||||||
@@ -56,6 +54,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard'));
|
|||||||
const About = lazy(() => import('./pages/About'));
|
const About = lazy(() => import('./pages/About'));
|
||||||
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
const UserAgreement = lazy(() => import('./pages/UserAgreement'));
|
||||||
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'));
|
||||||
|
const Playground = lazy(() => import('./pages/Playground'));
|
||||||
|
const ImageStudio = lazy(() => import('./pages/ImageStudio'));
|
||||||
|
|
||||||
function DynamicOAuth2Callback() {
|
function DynamicOAuth2Callback() {
|
||||||
const { provider } = useParams();
|
const { provider } = useParams();
|
||||||
@@ -152,7 +152,9 @@ function App() {
|
|||||||
path='/console/playground'
|
path='/console/playground'
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
|
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||||
<Playground />
|
<Playground />
|
||||||
|
</Suspense>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -88,13 +88,14 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
// ========== 数据处理 ==========
|
// ========== 数据处理 ==========
|
||||||
const initChart = async () => {
|
const initChart = async () => {
|
||||||
await dashboardData.loadQuotaData().then((data) => {
|
const [data] = await Promise.all([
|
||||||
|
dashboardData.loadQuotaData(),
|
||||||
|
dashboardData.loadUptimeData(),
|
||||||
|
dashboardData.loadCacheStats?.(),
|
||||||
|
]);
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
dashboardCharts.updateChartData(data);
|
dashboardCharts.updateChartData(data);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
await dashboardData.loadUptimeData();
|
|
||||||
await dashboardData.loadCacheStats?.();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||||
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing';
|
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing';
|
||||||
|
import SettingsImageStudio from '../../pages/Setting/Drawing/SettingsImageStudio';
|
||||||
import { API, showError, toBoolean } from '../../helpers';
|
import { API, showError, toBoolean } from '../../helpers';
|
||||||
|
|
||||||
const DrawingSetting = () => {
|
const DrawingSetting = () => {
|
||||||
@@ -76,6 +77,9 @@ const DrawingSetting = () => {
|
|||||||
<Card style={{ marginTop: '10px' }}>
|
<Card style={{ marginTop: '10px' }}>
|
||||||
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
<SettingsDrawing options={inputs} refresh={onRefresh} />
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card style={{ marginTop: '10px' }}>
|
||||||
|
<SettingsImageStudio options={inputs} refresh={onRefresh} />
|
||||||
|
</Card>
|
||||||
</Spin>
|
</Spin>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -409,10 +409,10 @@ const NotificationSettings = ({
|
|||||||
}
|
}
|
||||||
placeholder={t('请输入预警额度')}
|
placeholder={t('请输入预警额度')}
|
||||||
data={[
|
data={[
|
||||||
{ value: 100000, label: '0.2$' },
|
{ value: 100000, label: '0.2¥' },
|
||||||
{ value: 500000, label: '1$' },
|
{ value: 500000, label: '1¥' },
|
||||||
{ value: 1000000, label: '2$' },
|
{ value: 1000000, label: '2¥' },
|
||||||
{ value: 5000000, label: '10$' },
|
{ value: 5000000, label: '10¥' },
|
||||||
]}
|
]}
|
||||||
onChange={(val) => handleFormChange('warningThreshold', val)}
|
onChange={(val) => handleFormChange('warningThreshold', val)}
|
||||||
prefix={<IconBell />}
|
prefix={<IconBell />}
|
||||||
@@ -515,7 +515,7 @@ const NotificationSettings = ({
|
|||||||
title: '额度预警通知',
|
title: '额度预警通知',
|
||||||
content:
|
content:
|
||||||
'您的额度即将用尽,当前剩余额度为 {{value}}',
|
'您的额度即将用尽,当前剩余额度为 {{value}}',
|
||||||
values: ['$0.99'],
|
values: ['¥0.99'],
|
||||||
timestamp: 1739950503,
|
timestamp: 1739950503,
|
||||||
}}
|
}}
|
||||||
title='webhook'
|
title='webhook'
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ export const getChannelsColumns = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: COLUMN_KEYS.PRIORITY,
|
key: COLUMN_KEYS.PRIORITY,
|
||||||
title: t('优先级'),
|
title: t('同组优先级'),
|
||||||
dataIndex: 'priority',
|
dataIndex: 'priority',
|
||||||
render: (text, record, index) => {
|
render: (text, record, index) => {
|
||||||
if (record.children === undefined) {
|
if (record.children === undefined) {
|
||||||
@@ -602,9 +602,9 @@ export const getChannelsColumns = ({
|
|||||||
keepFocus={true}
|
keepFocus={true}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
Modal.warning({
|
Modal.warning({
|
||||||
title: t('修改子渠道优先级'),
|
title: t('修改子渠道同组优先级'),
|
||||||
content:
|
content:
|
||||||
t('确定要修改所有子渠道优先级为 ') +
|
t('确定要修改所有子渠道同组优先级为 ') +
|
||||||
e.target.value +
|
e.target.value +
|
||||||
t(' 吗?'),
|
t(' 吗?'),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
@@ -629,7 +629,7 @@ export const getChannelsColumns = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: COLUMN_KEYS.WEIGHT,
|
key: COLUMN_KEYS.WEIGHT,
|
||||||
title: t('权重'),
|
title: t('同优先级顺序'),
|
||||||
dataIndex: 'weight',
|
dataIndex: 'weight',
|
||||||
render: (text, record, index) => {
|
render: (text, record, index) => {
|
||||||
if (record.children === undefined) {
|
if (record.children === undefined) {
|
||||||
@@ -657,9 +657,9 @@ export const getChannelsColumns = ({
|
|||||||
keepFocus={true}
|
keepFocus={true}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
Modal.warning({
|
Modal.warning({
|
||||||
title: t('修改子渠道权重'),
|
title: t('修改子渠道同优先级顺序'),
|
||||||
content:
|
content:
|
||||||
t('确定要修改所有子渠道权重为 ') +
|
t('确定要修改所有子渠道同优先级顺序为 ') +
|
||||||
e.target.value +
|
e.target.value +
|
||||||
t(' 吗?'),
|
t(' 吗?'),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
|
|||||||
@@ -3540,8 +3540,8 @@ const EditChannelModal = (props) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='priority'
|
field='priority'
|
||||||
label={t('渠道优先级')}
|
label={t('同组优先级')}
|
||||||
placeholder={t('渠道优先级')}
|
placeholder={t('同组优先级')}
|
||||||
min={0}
|
min={0}
|
||||||
onNumberChange={(value) =>
|
onNumberChange={(value) =>
|
||||||
handleInputChange('priority', value)
|
handleInputChange('priority', value)
|
||||||
@@ -3552,8 +3552,8 @@ const EditChannelModal = (props) => {
|
|||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='weight'
|
field='weight'
|
||||||
label={t('渠道权重')}
|
label={t('同优先级顺序')}
|
||||||
placeholder={t('渠道权重')}
|
placeholder={t('同优先级顺序')}
|
||||||
min={0}
|
min={0}
|
||||||
onNumberChange={(value) =>
|
onNumberChange={(value) =>
|
||||||
handleInputChange('weight', value)
|
handleInputChange('weight', value)
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ const ViewDetailsModal = ({ visible, onCancel, deployment, t }) => {
|
|||||||
<div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
|
<div className='flex items-center justify-between p-3 bg-green-50 rounded-lg'>
|
||||||
<Text>{t('已支付金额')}</Text>
|
<Text>{t('已支付金额')}</Text>
|
||||||
<Text strong className='text-lg text-green-600'>
|
<Text strong className='text-lg text-green-600'>
|
||||||
$
|
¥
|
||||||
{details.amount_paid
|
{details.amount_paid
|
||||||
? details.amount_paid.toFixed(2)
|
? details.amount_paid.toFixed(2)
|
||||||
: '0.00'}{' '}
|
: '0.00'}{' '}
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ const PricingDisplaySettings = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const currencyItems = [
|
const currencyItems = [
|
||||||
{ value: 'USD', label: 'USD ($)' },
|
{ value: 'USD', label: 'USD (¥)' },
|
||||||
{ value: 'CNY', label: 'CNY (¥)' },
|
{ value: 'CNY', label: 'CNY (¥)' },
|
||||||
{ value: 'CUSTOM', label: t('自定义货币') },
|
{ value: 'CUSTOM', label: t('自定义货币') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -307,12 +307,12 @@ const EditRedemptionModal = (props) => {
|
|||||||
Number(values.quota) || 0,
|
Number(values.quota) || 0,
|
||||||
)}
|
)}
|
||||||
data={[
|
data={[
|
||||||
{ value: 500000, label: '1$' },
|
{ value: 500000, label: '1¥' },
|
||||||
{ value: 5000000, label: '10$' },
|
{ value: 5000000, label: '10¥' },
|
||||||
{ value: 25000000, label: '50$' },
|
{ value: 25000000, label: '50¥' },
|
||||||
{ value: 50000000, label: '100$' },
|
{ value: 50000000, label: '100¥' },
|
||||||
{ value: 250000000, label: '500$' },
|
{ value: 250000000, label: '500¥' },
|
||||||
{ value: 500000000, label: '1000$' },
|
{ value: 500000000, label: '1000¥' },
|
||||||
]}
|
]}
|
||||||
showClear
|
showClear
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -605,12 +605,12 @@ const EditTokenModal = (props) => {
|
|||||||
: [{ required: true, message: t('请输入额度') }]
|
: [{ required: true, message: t('请输入额度') }]
|
||||||
}
|
}
|
||||||
data={[
|
data={[
|
||||||
{ value: 500000, label: '1$' },
|
{ value: 500000, label: '1¥' },
|
||||||
{ value: 5000000, label: '10$' },
|
{ value: 5000000, label: '10¥' },
|
||||||
{ value: 25000000, label: '50$' },
|
{ value: 25000000, label: '50¥' },
|
||||||
{ value: 50000000, label: '100$' },
|
{ value: 50000000, label: '100¥' },
|
||||||
{ value: 250000000, label: '500$' },
|
{ value: 250000000, label: '500¥' },
|
||||||
{ value: 500000000, label: '1000$' },
|
{ value: 500000000, label: '1000¥' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -105,6 +105,21 @@ const RechargeCard = ({
|
|||||||
const [activeTab, setActiveTab] = useState('topup');
|
const [activeTab, setActiveTab] = useState('topup');
|
||||||
const shouldShowSubscription =
|
const shouldShowSubscription =
|
||||||
!subscriptionLoading && subscriptionPlans.length > 0;
|
!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(() => {
|
useEffect(() => {
|
||||||
if (initialTabSetRef.current) return;
|
if (initialTabSetRef.current) return;
|
||||||
@@ -291,11 +306,11 @@ const RechargeCard = ({
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
{payMethods && payMethods.filter(m => m.type !== 'waffo').length > 0 && (
|
{nonWaffoPayMethods.length > 0 && (
|
||||||
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
<Col xs={24} sm={24} md={24} lg={14} xl={14}>
|
||||||
<Form.Slot label={t('选择支付方式')}>
|
<Form.Slot label={t('选择支付方式')}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
{payMethods.filter(m => m.type !== 'waffo').map((payMethod) => {
|
{nonWaffoPayMethods.map((payMethod) => {
|
||||||
const minTopupVal = Number(payMethod.min_topup) || 0;
|
const minTopupVal = Number(payMethod.min_topup) || 0;
|
||||||
const isStripe = payMethod.type === 'stripe';
|
const isStripe = payMethod.type === 'stripe';
|
||||||
const disabled =
|
const disabled =
|
||||||
@@ -366,11 +381,7 @@ const RechargeCard = ({
|
|||||||
label={
|
label={
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span>{t('选择充值额度')}</span>
|
<span>{t('选择充值额度')}</span>
|
||||||
{(() => {
|
{currencyConfig.type !== 'USD' && (
|
||||||
const { symbol, rate, type } = getCurrencyConfig();
|
|
||||||
if (type === 'USD') return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--semi-color-text-2)',
|
color: 'var(--semi-color-text-2)',
|
||||||
@@ -378,10 +389,10 @@ const RechargeCard = ({
|
|||||||
fontWeight: 'normal',
|
fontWeight: 'normal',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
(1 $ = {rate.toFixed(2)} {symbol})
|
(1 ¥ = {currencyConfig.rate.toFixed(2)}{' '}
|
||||||
|
{currencyConfig.symbol})
|
||||||
</span>
|
</span>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -396,15 +407,7 @@ const RechargeCard = ({
|
|||||||
const save = originalPrice - discountedPrice;
|
const save = originalPrice - discountedPrice;
|
||||||
|
|
||||||
// 根据当前货币类型换算显示金额和数量
|
// 根据当前货币类型换算显示金额和数量
|
||||||
const { symbol, rate, type } = getCurrencyConfig();
|
const { symbol, rate, type } = currencyConfig;
|
||||||
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) { }
|
|
||||||
|
|
||||||
let displayValue = preset.value; // 显示的数量
|
let displayValue = preset.value; // 显示的数量
|
||||||
let displayActualPay = actualPay;
|
let displayActualPay = actualPay;
|
||||||
@@ -412,16 +415,16 @@ const RechargeCard = ({
|
|||||||
|
|
||||||
if (type === 'USD') {
|
if (type === 'USD') {
|
||||||
// 数量保持USD,价格从CNY转USD
|
// 数量保持USD,价格从CNY转USD
|
||||||
displayActualPay = actualPay / usdRate;
|
displayActualPay = actualPay / usdExchangeRate;
|
||||||
displaySave = save / usdRate;
|
displaySave = save / usdExchangeRate;
|
||||||
} else if (type === 'CNY') {
|
} else if (type === 'CNY') {
|
||||||
// 数量转CNY,价格已是CNY
|
// 数量转CNY,价格已是CNY
|
||||||
displayValue = preset.value * usdRate;
|
displayValue = preset.value * usdExchangeRate;
|
||||||
} else if (type === 'CUSTOM') {
|
} else if (type === 'CUSTOM') {
|
||||||
// 数量和价格都转自定义货币
|
// 数量和价格都转自定义货币
|
||||||
displayValue = preset.value * rate;
|
displayValue = preset.value * rate;
|
||||||
displayActualPay = (actualPay / usdRate) * rate;
|
displayActualPay = (actualPay / usdExchangeRate) * rate;
|
||||||
displaySave = (save / usdRate) * rate;
|
displaySave = (save / usdExchangeRate) * rate;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -540,7 +543,7 @@ const RechargeCard = ({
|
|||||||
{t('充值额度')}: {product.quota}
|
{t('充值额度')}: {product.quota}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-lg font-semibold text-blue-600'>
|
<div className='text-lg font-semibold text-blue-600'>
|
||||||
{product.currency === 'EUR' ? '€' : '$'}
|
{product.currency === 'EUR' ? '€' : '¥'}
|
||||||
{product.price}
|
{product.price}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -753,7 +753,7 @@ const TopUp = () => {
|
|||||||
|
|
||||||
{/* Creem 充值确认模态框 */}
|
{/* Creem 充值确认模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
title={t('确定要充值 $')}
|
title={t('确定要充值 ¥')}
|
||||||
visible={creemOpen}
|
visible={creemOpen}
|
||||||
onOk={onlineCreemTopUp}
|
onOk={onlineCreemTopUp}
|
||||||
onCancel={handleCreemCancel}
|
onCancel={handleCreemCancel}
|
||||||
@@ -768,7 +768,7 @@ const TopUp = () => {
|
|||||||
{t('产品名称')}:{selectedCreemProduct.name}
|
{t('产品名称')}:{selectedCreemProduct.name}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
|
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '¥'}
|
||||||
{selectedCreemProduct.price}
|
{selectedCreemProduct.price}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
|||||||
title: t('支付金额'),
|
title: t('支付金额'),
|
||||||
dataIndex: 'money',
|
dataIndex: 'money',
|
||||||
key: 'money',
|
key: 'money',
|
||||||
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
|
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('状态'),
|
title: t('状态'),
|
||||||
|
|||||||
18
web/src/helpers/render.jsx
vendored
18
web/src/helpers/render.jsx
vendored
@@ -999,9 +999,9 @@ export function renderQuotaNumberWithDigit(num, digits = 2) {
|
|||||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
num = num.toFixed(digits);
|
num = num.toFixed(digits);
|
||||||
if (quotaDisplayType === 'CNY') {
|
if (quotaDisplayType === 'CNY') {
|
||||||
return '¥' + num;
|
return '¥' + num;
|
||||||
} else if (quotaDisplayType === 'USD') {
|
} else if (quotaDisplayType === 'USD') {
|
||||||
return '$' + num;
|
return '¥' + num;
|
||||||
} else if (quotaDisplayType === 'CUSTOM') {
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
let symbol = '¤';
|
let symbol = '¤';
|
||||||
@@ -1077,7 +1077,7 @@ export function renderQuotaWithAmount(amount) {
|
|||||||
: amount;
|
: amount;
|
||||||
|
|
||||||
if (quotaDisplayType === 'CNY') {
|
if (quotaDisplayType === 'CNY') {
|
||||||
return '¥' + formattedAmount;
|
return '¥' + formattedAmount;
|
||||||
} else if (quotaDisplayType === 'CUSTOM') {
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
let symbol = '¤';
|
let symbol = '¤';
|
||||||
@@ -1089,7 +1089,7 @@ export function renderQuotaWithAmount(amount) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return symbol + formattedAmount;
|
return symbol + formattedAmount;
|
||||||
}
|
}
|
||||||
return '$' + formattedAmount;
|
return '¥' + formattedAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1100,11 +1100,11 @@ export function getCurrencyConfig() {
|
|||||||
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
|
|
||||||
let symbol = '$';
|
let symbol = '¥';
|
||||||
let rate = 1;
|
let rate = 1;
|
||||||
|
|
||||||
if (quotaDisplayType === 'CNY') {
|
if (quotaDisplayType === 'CNY') {
|
||||||
symbol = '¥';
|
symbol = '¥';
|
||||||
try {
|
try {
|
||||||
if (statusStr) {
|
if (statusStr) {
|
||||||
const s = JSON.parse(statusStr);
|
const s = JSON.parse(statusStr);
|
||||||
@@ -1144,7 +1144,7 @@ export function renderQuota(quota, digits = 2) {
|
|||||||
return renderNumber(quota);
|
return renderNumber(quota);
|
||||||
}
|
}
|
||||||
const resultUSD = quota / quotaPerUnit;
|
const resultUSD = quota / quotaPerUnit;
|
||||||
let symbol = '$';
|
let symbol = '¥';
|
||||||
let value = resultUSD;
|
let value = resultUSD;
|
||||||
if (quotaDisplayType === 'CNY') {
|
if (quotaDisplayType === 'CNY') {
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
@@ -1156,7 +1156,7 @@ export function renderQuota(quota, digits = 2) {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
value = resultUSD * usdRate;
|
value = resultUSD * usdRate;
|
||||||
symbol = '¥';
|
symbol = '¥';
|
||||||
} else if (quotaDisplayType === 'CUSTOM') {
|
} else if (quotaDisplayType === 'CUSTOM') {
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
let symbolCustom = '¤';
|
let symbolCustom = '¤';
|
||||||
@@ -2375,7 +2375,7 @@ export function renderAudioModelPrice(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1 ratio = $0.002 / 1K tokens
|
// 1 ratio = ¥0.002 / 1K tokens
|
||||||
if (modelPrice !== -1) {
|
if (modelPrice !== -1) {
|
||||||
return i18next.t(
|
return i18next.t(
|
||||||
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
'模型价格:{{symbol}}{{price}} * {{ratioType}}:{{ratio}} = {{symbol}}{{total}}',
|
||||||
|
|||||||
4
web/src/helpers/utils.jsx
vendored
4
web/src/helpers/utils.jsx
vendored
@@ -676,9 +676,9 @@ export const calculateModelPrice = ({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let symbol = '$';
|
let symbol = '¥';
|
||||||
if (currency === 'CNY') {
|
if (currency === 'CNY') {
|
||||||
symbol = '¥';
|
symbol = '¥';
|
||||||
} else if (currency === 'CUSTOM') {
|
} else if (currency === 'CUSTOM') {
|
||||||
try {
|
try {
|
||||||
const statusStr = localStorage.getItem('status');
|
const statusStr = localStorage.getItem('status');
|
||||||
|
|||||||
4
web/src/hooks/channels/useChannelsData.jsx
vendored
4
web/src/hooks/channels/useChannelsData.jsx
vendored
@@ -620,7 +620,7 @@ export const useChannelsData = () => {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'priority':
|
case 'priority':
|
||||||
if (data.priority === undefined || data.priority === '') {
|
if (data.priority === undefined || data.priority === '') {
|
||||||
showInfo('优先级必须是整数!');
|
showInfo('同组优先级必须是整数!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
data.priority = parseInt(data.priority);
|
data.priority = parseInt(data.priority);
|
||||||
@@ -631,7 +631,7 @@ export const useChannelsData = () => {
|
|||||||
data.weight < 0 ||
|
data.weight < 0 ||
|
||||||
data.weight === ''
|
data.weight === ''
|
||||||
) {
|
) {
|
||||||
showInfo('权重必须是非负整数!');
|
showInfo('同优先级顺序必须是非负整数!');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
data.weight = parseInt(data.weight);
|
data.weight = parseInt(data.weight);
|
||||||
|
|||||||
8
web/src/hooks/dashboard/useDashboardData.js
vendored
8
web/src/hooks/dashboard/useDashboardData.js
vendored
@@ -243,9 +243,11 @@ export const useDashboardData = (userState, userDispatch, statusState) => {
|
|||||||
}, [userDispatch]);
|
}, [userDispatch]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const data = await loadQuotaData();
|
const [data] = await Promise.all([
|
||||||
await loadUptimeData();
|
loadQuotaData(),
|
||||||
await loadCacheStats();
|
loadUptimeData(),
|
||||||
|
loadCacheStats(),
|
||||||
|
]);
|
||||||
return data;
|
return data;
|
||||||
}, [loadQuotaData, loadUptimeData, loadCacheStats]);
|
}, [loadQuotaData, loadUptimeData, loadCacheStats]);
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export const useModelPricingData = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (currency === 'CNY') {
|
if (currency === 'CNY') {
|
||||||
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
return `¥${(priceInUSD * usdExchangeRate).toFixed(3)}`;
|
||||||
} else if (currency === 'CUSTOM') {
|
} else if (currency === 'CUSTOM') {
|
||||||
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
return `${customCurrencySymbol}${(priceInUSD * customExchangeRate).toFixed(3)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
8
web/src/i18n/locales/en.json
vendored
8
web/src/i18n/locales/en.json
vendored
@@ -30,7 +30,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": " | Based on ",
|
"| 基于": " | Based on ",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - Lowest",
|
"0 - 最低": "0 - Lowest",
|
||||||
"0 表示不限": "0 means unlimited",
|
"0 表示不限": "0 means unlimited",
|
||||||
"0.002-1之间的小数": "Decimal between 0.002-1",
|
"0.002-1之间的小数": "Decimal between 0.002-1",
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
"例如:0001": "e.g.: 0001",
|
"例如:0001": "e.g.: 0001",
|
||||||
"例如:1000": "e.g.: 1000",
|
"例如:1000": "e.g.: 1000",
|
||||||
"例如:100000": "e.g.: 100000",
|
"例如: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",
|
"例如:2000": "e.g.: 2000",
|
||||||
"例如:4.99": "e.g.: 4.99",
|
"例如:4.99": "e.g.: 4.99",
|
||||||
"例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
|
"例如:401, 403, 429, 500-599": "e.g. 401,403,429,500-599",
|
||||||
@@ -745,7 +745,7 @@
|
|||||||
"剩余时间": "Remaining Time",
|
"剩余时间": "Remaining Time",
|
||||||
"剩余额度": "Remaining quota",
|
"剩余额度": "Remaining quota",
|
||||||
"剩余额度/总额度": "Remaining/Total",
|
"剩余额度/总额度": "Remaining/Total",
|
||||||
"剩余额度$": "Remaining quota $",
|
"剩余额度$": "Remaining quota ¥",
|
||||||
"功能特性": "Features",
|
"功能特性": "Features",
|
||||||
"加入渠道": "Join Channel",
|
"加入渠道": "Join Channel",
|
||||||
"加入预填组": "Join Pre-filled Group",
|
"加入预填组": "Join Pre-filled Group",
|
||||||
@@ -2170,7 +2170,7 @@
|
|||||||
"确定清除所有失效兑换码?": "Are you sure you want to clear all invalid redemption codes?",
|
"确定清除所有失效兑换码?": "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 priorities to ",
|
||||||
"确定要修改所有子渠道权重为 ": "Confirm to modify all sub-channel weights 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.",
|
"确定要删除供应商 \"{{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?",
|
"确定要删除所有已自动禁用的密钥吗?": "Are you sure you want to delete all automatically disabled keys?",
|
||||||
"确定要删除所选的 {{count}} 个令牌吗?_one": "Are you sure you want to delete the selected {{count}} token?",
|
"确定要删除所选的 {{count}} 个令牌吗?_one": "Are you sure you want to delete the selected {{count}} token?",
|
||||||
|
|||||||
8
web/src/i18n/locales/fr.json
vendored
8
web/src/i18n/locales/fr.json
vendored
@@ -33,7 +33,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• Des en-têtes ou une authentification spécifiques sont requis",
|
"• 需要特定的请求头或认证": "• Des en-têtes ou une authentification spécifiques sont requis",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": " | Basé sur ",
|
"| 基于": " | Basé sur ",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - La plus basse",
|
"0 - 最低": "0 - La plus basse",
|
||||||
"0 表示不限": "0 signifie illimité",
|
"0 表示不限": "0 signifie illimité",
|
||||||
"0.002-1之间的小数": "Décimal entre 0,002-1",
|
"0.002-1之间的小数": "Décimal entre 0,002-1",
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
"例如:0001": "Par exemple : 0001",
|
"例如:0001": "Par exemple : 0001",
|
||||||
"例如:1000": "Par exemple : 1000",
|
"例如:1000": "Par exemple : 1000",
|
||||||
"例如:100000": "Ex. : 100000",
|
"例如: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",
|
"例如:2000": "Par exemple : 2000",
|
||||||
"例如:4.99": "Ex. : 4.99",
|
"例如:4.99": "Ex. : 4.99",
|
||||||
"例如:401, 403, 429, 500-599": "ex. : 401, 403, 429, 500-599",
|
"例如:401, 403, 429, 500-599": "ex. : 401, 403, 429, 500-599",
|
||||||
@@ -729,7 +729,7 @@
|
|||||||
"剩余时间": "Remaining Time",
|
"剩余时间": "Remaining Time",
|
||||||
"剩余额度": "Quota restant",
|
"剩余额度": "Quota restant",
|
||||||
"剩余额度/总额度": "Restant/Total",
|
"剩余额度/总额度": "Restant/Total",
|
||||||
"剩余额度$": "Quota restant $",
|
"剩余额度$": "Quota restant ¥",
|
||||||
"功能特性": "Fonctionnalités",
|
"功能特性": "Fonctionnalités",
|
||||||
"加入渠道": "Join Channel",
|
"加入渠道": "Join Channel",
|
||||||
"加入预填组": "Rejoindre un groupe pré-rempli",
|
"加入预填组": "Rejoindre un groupe pré-rempli",
|
||||||
@@ -2135,7 +2135,7 @@
|
|||||||
"确定清除所有失效兑换码?": "Êtes-vous sûr de vouloir effacer tous les codes d'échange non valides ?",
|
"确定清除所有失效兑换码?": "Ê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 toutes les priorités des sous-canaux en ",
|
||||||
"确定要修改所有子渠道权重为 ": "Confirmer la modification de tous les poids 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.",
|
"确定要删除供应商 \"{{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 ?",
|
"确定要删除所有已自动禁用的密钥吗?": "Ê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é ?",
|
"确定要删除所选的 {{count}} 个令牌吗?_one": "Êtes-vous sûr de vouloir supprimer le jeton sélectionné ?",
|
||||||
|
|||||||
8
web/src/i18n/locales/ja.json
vendored
8
web/src/i18n/locales/ja.json
vendored
@@ -29,7 +29,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": "| ベース: ",
|
"| 基于": "| ベース: ",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - 最低",
|
"0 - 最低": "0 - 最低",
|
||||||
"0 表示不限": "0 は無制限を意味します",
|
"0 表示不限": "0 は無制限を意味します",
|
||||||
"0.002-1之间的小数": "0.002~1の小数",
|
"0.002-1之间的小数": "0.002~1の小数",
|
||||||
@@ -426,7 +426,7 @@
|
|||||||
"例如:0001": "例:0001",
|
"例如:0001": "例:0001",
|
||||||
"例如:1000": "例:1000",
|
"例如:1000": "例:1000",
|
||||||
"例如:100000": "e.g.: 100000",
|
"例如:100000": "e.g.: 100000",
|
||||||
"例如:2,就是最低充值2$": "例:2(最低チャージ額$2)",
|
"例如:2,就是最低充值2$": "例:2(最低チャージ額¥2)",
|
||||||
"例如:2000": "例:2000",
|
"例如:2000": "例:2000",
|
||||||
"例如:4.99": "e.g.: 4.99",
|
"例如:4.99": "e.g.: 4.99",
|
||||||
"例如:401, 403, 429, 500-599": "例:401, 403, 429, 500-599",
|
"例如:401, 403, 429, 500-599": "例:401, 403, 429, 500-599",
|
||||||
@@ -720,7 +720,7 @@
|
|||||||
"剩余时间": "Remaining Time",
|
"剩余时间": "Remaining Time",
|
||||||
"剩余额度": "残りクォータ",
|
"剩余额度": "残りクォータ",
|
||||||
"剩余额度/总额度": "残りクォータ/総クォータ",
|
"剩余额度/总额度": "残りクォータ/総クォータ",
|
||||||
"剩余额度$": "残高 ($)",
|
"剩余额度$": "残高 (¥)",
|
||||||
"功能特性": "機能",
|
"功能特性": "機能",
|
||||||
"加入渠道": "Join Channel",
|
"加入渠道": "Join Channel",
|
||||||
"加入预填组": "事前入力グループへの参加",
|
"加入预填组": "事前入力グループへの参加",
|
||||||
@@ -2118,7 +2118,7 @@
|
|||||||
"确定清除所有失效兑换码?": "すべての無効な引き換えコードを削除してもよろしいですか?",
|
"确定清除所有失效兑换码?": "すべての無効な引き換えコードを削除してもよろしいですか?",
|
||||||
"确定要修改所有子渠道优先级为 ": "すべてのサブチャネルの優先度を",
|
"确定要修改所有子渠道优先级为 ": "すべてのサブチャネルの優先度を",
|
||||||
"确定要修改所有子渠道权重为 ": "すべてのサブチャネルのウェイトを",
|
"确定要修改所有子渠道权重为 ": "すべてのサブチャネルのウェイトを",
|
||||||
"确定要充值 $": "Confirm to recharge $",
|
"确定要充值 $": "Confirm to recharge ¥",
|
||||||
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻すことができません。",
|
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻すことができません。",
|
||||||
"确定要删除所有已自动禁用的密钥吗?": "自動的に無効になったすべてのAPIキーを削除してもよろしいですか?",
|
"确定要删除所有已自动禁用的密钥吗?": "自動的に無効になったすべてのAPIキーを削除してもよろしいですか?",
|
||||||
"确定要删除所选的 {{count}} 个令牌吗?_one": "選択した{{count}}個のトークンを削除してもよろしいですか?_one",
|
"确定要删除所选的 {{count}} 个令牌吗?_one": "選択した{{count}}個のトークンを削除してもよろしいですか?_one",
|
||||||
|
|||||||
8
web/src/i18n/locales/ru.json
vendored
8
web/src/i18n/locales/ru.json
vendored
@@ -36,7 +36,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• Требуются специальные заголовки или авторизация",
|
"• 需要特定的请求头或认证": "• Требуются специальные заголовки или авторизация",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": "| Основано на",
|
"| 基于": "| Основано на",
|
||||||
"$/1M tokens": "$/1M токенов",
|
"$/1M tokens": "¥/1M токенов",
|
||||||
"0 - 最低": "0 - Минимум",
|
"0 - 最低": "0 - Минимум",
|
||||||
"0 表示不限": "0 означает без лимита",
|
"0 表示不限": "0 означает без лимита",
|
||||||
"0.002-1之间的小数": "Десятичное число между 0.002-1",
|
"0.002-1之间的小数": "Десятичное число между 0.002-1",
|
||||||
@@ -433,7 +433,7 @@
|
|||||||
"例如:0001": "например: 0001",
|
"例如:0001": "например: 0001",
|
||||||
"例如:1000": "например: 1000",
|
"例如:1000": "например: 1000",
|
||||||
"例如:100000": "Например: 100000",
|
"例如:100000": "Например: 100000",
|
||||||
"例如:2,就是最低充值2$": "например: 2, это минимальное пополнение 2$",
|
"例如:2,就是最低充值2$": "например: 2, это минимальное пополнение 2¥",
|
||||||
"例如:2000": "например: 2000",
|
"例如:2000": "например: 2000",
|
||||||
"例如:4.99": "Например: 4.99",
|
"例如:4.99": "Например: 4.99",
|
||||||
"例如:401, 403, 429, 500-599": "напр.: 401, 403, 429, 500-599",
|
"例如:401, 403, 429, 500-599": "напр.: 401, 403, 429, 500-599",
|
||||||
@@ -735,7 +735,7 @@
|
|||||||
"剩余时间": "Remaining Time",
|
"剩余时间": "Remaining Time",
|
||||||
"剩余额度": "Оставшаяся квота",
|
"剩余额度": "Оставшаяся квота",
|
||||||
"剩余额度/总额度": "Оставшаяся квота/Общая квота",
|
"剩余额度/总额度": "Оставшаяся квота/Общая квота",
|
||||||
"剩余额度$": "Оставшаяся квота$",
|
"剩余额度$": "Оставшаяся квота¥",
|
||||||
"功能特性": "Функциональные возможности",
|
"功能特性": "Функциональные возможности",
|
||||||
"加入渠道": "Join Channel",
|
"加入渠道": "Join Channel",
|
||||||
"加入预填组": "Присоединиться к группе предварительного заполнения",
|
"加入预填组": "Присоединиться к группе предварительного заполнения",
|
||||||
@@ -2147,7 +2147,7 @@
|
|||||||
"确定清除所有失效兑换码?": "Подтвердить очистку всех недействительных кодов купонов?",
|
"确定清除所有失效兑换码?": "Подтвердить очистку всех недействительных кодов купонов?",
|
||||||
"确定要修改所有子渠道优先级为 ": "Подтвердить изменение приоритета всех дочерних каналов на ",
|
"确定要修改所有子渠道优先级为 ": "Подтвердить изменение приоритета всех дочерних каналов на ",
|
||||||
"确定要修改所有子渠道权重为 ": "Подтвердить изменение веса всех дочерних каналов на ",
|
"确定要修改所有子渠道权重为 ": "Подтвердить изменение веса всех дочерних каналов на ",
|
||||||
"确定要充值 $": "Подтвердить пополнение на $",
|
"确定要充值 $": "Подтвердить пополнение на ¥",
|
||||||
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Подтвердить удаление поставщика \"{{name}}\"? Это действие нельзя отменить.",
|
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "Подтвердить удаление поставщика \"{{name}}\"? Это действие нельзя отменить.",
|
||||||
"确定要删除所有已自动禁用的密钥吗?": "Подтвердить удаление всех автоматически отключенных ключей?",
|
"确定要删除所有已自动禁用的密钥吗?": "Подтвердить удаление всех автоматически отключенных ключей?",
|
||||||
"确定要删除所选的 {{count}} 个令牌吗?_one": "Подтвердить удаление выбранного {{count}} токена?",
|
"确定要删除所选的 {{count}} 个令牌吗?_one": "Подтвердить удаление выбранного {{count}} токена?",
|
||||||
|
|||||||
8
web/src/i18n/locales/vi.json
vendored
8
web/src/i18n/locales/vi.json
vendored
@@ -29,7 +29,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
"• 需要特定的请求头或认证": "• Specific headers or authentication are required",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": " | Dựa trên ",
|
"| 基于": " | Dựa trên ",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - Thấp nhất",
|
"0 - 最低": "0 - Thấp nhất",
|
||||||
"0 表示不限": "0 nghĩa là không giới hạn",
|
"0 表示不限": "0 nghĩa là không giới hạn",
|
||||||
"0.002-1之间的小数": "Số thập phân giữa 0.002-1",
|
"0.002-1之间的小数": "Số thập phân giữa 0.002-1",
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
"例如:0001": "ví dụ: 0001",
|
"例如:0001": "ví dụ: 0001",
|
||||||
"例如:1000": "ví dụ: 1000",
|
"例如:1000": "ví dụ: 1000",
|
||||||
"例如:100000": "Ví dụ: 100000",
|
"例如: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",
|
"例如:2000": "ví dụ: 2000",
|
||||||
"例如:4.99": "Ví dụ: 4.99",
|
"例如:4.99": "Ví dụ: 4.99",
|
||||||
"例如:401, 403, 429, 500-599": "VD: 401, 403, 429, 500-599",
|
"例如:401, 403, 429, 500-599": "VD: 401, 403, 429, 500-599",
|
||||||
@@ -721,7 +721,7 @@
|
|||||||
"剩余时间": "Remaining Time",
|
"剩余时间": "Remaining Time",
|
||||||
"剩余额度": "Hạn ngạch còn lại",
|
"剩余额度": "Hạn ngạch còn lại",
|
||||||
"剩余额度/总额度": "Còn lại/Tổng cộng",
|
"剩余额度/总额度": "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",
|
"功能特性": "Tính năng",
|
||||||
"加入渠道": "Join Channel",
|
"加入渠道": "Join Channel",
|
||||||
"加入预填组": "Tham gia nhóm điền sẵn",
|
"加入预填组": "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?",
|
"确定清除所有失效兑换码?": "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 ư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 ",
|
"确定要修改所有子渠道权重为 ": "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.",
|
"确定要删除供应商 \"{{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 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?",
|
"确定要删除所有已自动禁用的密钥吗?": "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?",
|
||||||
|
|||||||
10
web/src/i18n/locales/zh-CN.json
vendored
10
web/src/i18n/locales/zh-CN.json
vendored
@@ -24,7 +24,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• 需要特定的请求头或认证",
|
"• 需要特定的请求头或认证": "• 需要特定的请求头或认证",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": "| 基于",
|
"| 基于": "| 基于",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - 最低",
|
"0 - 最低": "0 - 最低",
|
||||||
"0.002-1之间的小数": "0.002-1之间的小数",
|
"0.002-1之间的小数": "0.002-1之间的小数",
|
||||||
"0.1以上的小数": "0.1以上的小数",
|
"0.1以上的小数": "0.1以上的小数",
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
"例如:0001": "例如:0001",
|
"例如:0001": "例如:0001",
|
||||||
"例如:1000": "例如:1000",
|
"例如:1000": "例如:1000",
|
||||||
"例如:100000": "例如:100000",
|
"例如:100000": "例如:100000",
|
||||||
"例如:2,就是最低充值2$": "例如:2,就是最低充值2$",
|
"例如:2,就是最低充值2$": "例如:2,就是最低充值2¥",
|
||||||
"例如:2000": "例如:2000",
|
"例如:2000": "例如:2000",
|
||||||
"例如:4.99": "例如:4.99",
|
"例如:4.99": "例如:4.99",
|
||||||
"例如:7,就是7元/美金": "例如:7,就是7元/美金",
|
"例如:7,就是7元/美金": "例如:7,就是7元/美金",
|
||||||
@@ -594,7 +594,8 @@
|
|||||||
"剩余时间": "剩余时间",
|
"剩余时间": "剩余时间",
|
||||||
"剩余额度": "剩余额度",
|
"剩余额度": "剩余额度",
|
||||||
"剩余额度/总额度": "剩余额度/总额度",
|
"剩余额度/总额度": "剩余额度/总额度",
|
||||||
"剩余额度$": "剩余额度$",
|
"剩余额度$": "剩余额度¥",
|
||||||
|
"剩余额度¥": "剩余额度¥",
|
||||||
"功能特性": "功能特性",
|
"功能特性": "功能特性",
|
||||||
"加入渠道": "加入渠道",
|
"加入渠道": "加入渠道",
|
||||||
"加入预填组": "加入预填组",
|
"加入预填组": "加入预填组",
|
||||||
@@ -1749,7 +1750,8 @@
|
|||||||
"确定清除所有失效兑换码?": "确定清除所有失效兑换码?",
|
"确定清除所有失效兑换码?": "确定清除所有失效兑换码?",
|
||||||
"确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ",
|
"确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ",
|
||||||
"确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ",
|
"确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ",
|
||||||
"确定要充值 $": "确定要充值 $",
|
"确定要充值 $": "确定要充值 ¥",
|
||||||
|
"确定要充值 ¥": "确定要充值 ¥",
|
||||||
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。",
|
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。",
|
||||||
"确定要删除所有已自动禁用的密钥吗?": "确定要删除所有已自动禁用的密钥吗?",
|
"确定要删除所有已自动禁用的密钥吗?": "确定要删除所有已自动禁用的密钥吗?",
|
||||||
"确定要删除所选的 {{count}} 个令牌吗?_other": "确定要删除所选的 {{count}} 个令牌吗?",
|
"确定要删除所选的 {{count}} 个令牌吗?_other": "确定要删除所选的 {{count}} 个令牌吗?",
|
||||||
|
|||||||
10
web/src/i18n/locales/zh-TW.json
vendored
10
web/src/i18n/locales/zh-TW.json
vendored
@@ -24,7 +24,7 @@
|
|||||||
"• 需要特定的请求头或认证": "• 需要特定的請求頭或認證",
|
"• 需要特定的请求头或认证": "• 需要特定的請求頭或認證",
|
||||||
"© {{currentYear}}": "© {{currentYear}}",
|
"© {{currentYear}}": "© {{currentYear}}",
|
||||||
"| 基于": "| 基於",
|
"| 基于": "| 基於",
|
||||||
"$/1M tokens": "$/1M tokens",
|
"$/1M tokens": "¥/1M tokens",
|
||||||
"0 - 最低": "0 - 最低",
|
"0 - 最低": "0 - 最低",
|
||||||
"0.002-1之间的小数": "0.002-1之間的小數",
|
"0.002-1之间的小数": "0.002-1之間的小數",
|
||||||
"0.1以上的小数": "0.1以上的小數",
|
"0.1以上的小数": "0.1以上的小數",
|
||||||
@@ -339,7 +339,7 @@
|
|||||||
"例如:0001": "例如:0001",
|
"例如:0001": "例如:0001",
|
||||||
"例如:1000": "例如:1000",
|
"例如:1000": "例如:1000",
|
||||||
"例如:100000": "例如:100000",
|
"例如:100000": "例如:100000",
|
||||||
"例如:2,就是最低充值2$": "例如:2,就是最低儲值2$",
|
"例如:2,就是最低充值2$": "例如:2,就是最低儲值2¥",
|
||||||
"例如:2000": "例如:2000",
|
"例如:2000": "例如:2000",
|
||||||
"例如:4.99": "例如:4.99",
|
"例如:4.99": "例如:4.99",
|
||||||
"例如:7,就是7元/美金": "例如:7,就是7元/美金",
|
"例如:7,就是7元/美金": "例如:7,就是7元/美金",
|
||||||
@@ -584,7 +584,8 @@
|
|||||||
"剩余时间": "剩餘時間",
|
"剩余时间": "剩餘時間",
|
||||||
"剩余额度": "剩餘額度",
|
"剩余额度": "剩餘額度",
|
||||||
"剩余额度/总额度": "剩餘額度/總額度",
|
"剩余额度/总额度": "剩餘額度/總額度",
|
||||||
"剩余额度$": "剩餘額度$",
|
"剩余额度$": "剩餘額度¥",
|
||||||
|
"剩余额度¥": "剩餘額度¥",
|
||||||
"功能特性": "功能特性",
|
"功能特性": "功能特性",
|
||||||
"加入渠道": "加入管道",
|
"加入渠道": "加入管道",
|
||||||
"加入预填组": "加入預填組",
|
"加入预填组": "加入預填組",
|
||||||
@@ -1743,7 +1744,8 @@
|
|||||||
"确定清除所有失效兑换码?": "確定清除所有失效兌換碼?",
|
"确定清除所有失效兑换码?": "確定清除所有失效兌換碼?",
|
||||||
"确定要修改所有子渠道优先级为 ": "確定要修改所有子管道優先級為 ",
|
"确定要修改所有子渠道优先级为 ": "確定要修改所有子管道優先級為 ",
|
||||||
"确定要修改所有子渠道权重为 ": "確定要修改所有子管道權重為 ",
|
"确定要修改所有子渠道权重为 ": "確定要修改所有子管道權重為 ",
|
||||||
"确定要充值 $": "確定要儲值 $",
|
"确定要充值 $": "確定要儲值 ¥",
|
||||||
|
"确定要充值 ¥": "確定要儲值 ¥",
|
||||||
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "確定要刪除供應商 \"{{name}}\" 嗎?此操作不可撤銷。",
|
"确定要删除供应商 \"{{name}}\" 吗?此操作不可撤销。": "確定要刪除供應商 \"{{name}}\" 嗎?此操作不可撤銷。",
|
||||||
"确定要删除所有已自动禁用的密钥吗?": "確定要刪除所有已自動禁用的密鑰嗎?",
|
"确定要删除所有已自动禁用的密钥吗?": "確定要刪除所有已自動禁用的密鑰嗎?",
|
||||||
"确定要删除所选的 {{count}} 个令牌吗?_other": "確定要刪除所選的 {{count}} 個令牌嗎?",
|
"确定要删除所选的 {{count}} 个令牌吗?_other": "確定要刪除所選的 {{count}} 個令牌嗎?",
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ const QUALITY_OPTIONS = [
|
|||||||
{ value: 'high', label: 'High', description: '更高质量,通常更慢' },
|
{ 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 = []) => {
|
const sortModels = (models = []) => {
|
||||||
return [...models].sort((a, b) => {
|
return [...models].sort((a, b) => {
|
||||||
if (a.model_name === 'gpt-image-2') return -1;
|
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];
|
return SIZE_OPTIONS.find((item) => item.value === size) || SIZE_OPTIONS[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSizeRatio = (size) => getSizeMeta(size)?.ratioLabel || 'Auto';
|
||||||
|
|
||||||
const getImageMimeType = (item) => {
|
const getImageMimeType = (item) => {
|
||||||
const format = String(item?.output_format || '').trim().toLowerCase();
|
const format = String(item?.output_format || '').trim().toLowerCase();
|
||||||
if (format === 'jpeg' || format === 'jpg') {
|
if (format === 'jpeg' || format === 'jpg') {
|
||||||
@@ -208,6 +219,8 @@ const normalizeImageResults = (payload) => {
|
|||||||
.map((item, index) => ({
|
.map((item, index) => ({
|
||||||
id: `${Date.now()}-${index}`,
|
id: `${Date.now()}-${index}`,
|
||||||
src: buildImageSrc(item, urls),
|
src: buildImageSrc(item, urls),
|
||||||
|
rawUrl: item?.url || '',
|
||||||
|
taskResultUrl: item?.task_result_url || '',
|
||||||
revisedPrompt: item?.revised_prompt || '',
|
revisedPrompt: item?.revised_prompt || '',
|
||||||
downloadName: `gpt-image-${index + 1}.${getImageExtension(item)}`,
|
downloadName: `gpt-image-${index + 1}.${getImageExtension(item)}`,
|
||||||
}))
|
}))
|
||||||
@@ -236,12 +249,14 @@ const ImageStudio = () => {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState('generate');
|
const [activeTab, setActiveTab] = useState('generate');
|
||||||
const [tokens, setTokens] = useState([]);
|
const [tokens, setTokens] = useState([]);
|
||||||
const [pricingModels, setPricingModels] = useState([]);
|
const [imageStudioModels, setImageStudioModels] = useState([]);
|
||||||
|
const [studioSettings, setStudioSettings] = useState(DEFAULT_STUDIO_SETTINGS);
|
||||||
const [selectedTokenId, setSelectedTokenId] = useState('');
|
const [selectedTokenId, setSelectedTokenId] = useState('');
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [prompt, setPrompt] = useState('');
|
const [prompt, setPrompt] = useState('');
|
||||||
const [selectedSize, setSelectedSize] = useState('auto');
|
const [selectedSize, setSelectedSize] = useState('auto');
|
||||||
const [selectedQuality, setSelectedQuality] = useState('auto');
|
const [selectedQuality, setSelectedQuality] = useState('auto');
|
||||||
|
const [selectedRatio, setSelectedRatio] = useState('Auto');
|
||||||
const [referenceImages, setReferenceImages] = useState([]);
|
const [referenceImages, setReferenceImages] = useState([]);
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [resultMeta, setResultMeta] = useState(null);
|
const [resultMeta, setResultMeta] = useState(null);
|
||||||
@@ -269,9 +284,9 @@ const ImageStudio = () => {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [tokensRes, pricingRes] = await Promise.all([
|
const [tokensRes, settingsRes] = await Promise.all([
|
||||||
API.get('/api/token/?p=1&size=1000'),
|
API.get('/api/token/?p=1&size=1000'),
|
||||||
API.get('/api/pricing'),
|
API.get('/api/image-studio/settings'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tokenPayload = tokensRes?.data?.data;
|
const tokenPayload = tokensRes?.data?.data;
|
||||||
@@ -283,10 +298,21 @@ const ImageStudio = () => {
|
|||||||
);
|
);
|
||||||
setTokens(imageTokens);
|
setTokens(imageTokens);
|
||||||
|
|
||||||
const pricingPayload = Array.isArray(pricingRes?.data?.data)
|
const nextSettings = settingsRes?.data?.data || DEFAULT_STUDIO_SETTINGS;
|
||||||
? pricingRes.data.data
|
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) {
|
} catch (error) {
|
||||||
showError(t('加载图片制作配置失败,请稍后重试'));
|
showError(t('加载图片制作配置失败,请稍后重试'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -298,12 +324,14 @@ const ImageStudio = () => {
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const imageGroupModels = useMemo(() => {
|
const imageGroupModels = useMemo(() => {
|
||||||
return pricingModels.filter(
|
return imageStudioModels.filter(
|
||||||
(model) =>
|
(model) =>
|
||||||
Array.isArray(model?.enable_groups) &&
|
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';
|
const endpointType = activeTab === 'edit' ? 'image-edit' : 'image-generation';
|
||||||
|
|
||||||
@@ -329,6 +357,52 @@ const ImageStudio = () => {
|
|||||||
return sortModels(matched);
|
return sortModels(matched);
|
||||||
}, [endpointModels, selectedToken]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!tokens.length) {
|
if (!tokens.length) {
|
||||||
if (selectedTokenId) {
|
if (selectedTokenId) {
|
||||||
@@ -355,11 +429,12 @@ const ImageStudio = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferredModel =
|
const preferredModel =
|
||||||
|
availableModels.find((model) => model.model_name === studioSettings.default_model) ||
|
||||||
availableModels.find((model) => model.model_name === 'gpt-image-2') ||
|
availableModels.find((model) => model.model_name === 'gpt-image-2') ||
|
||||||
availableModels[0];
|
availableModels[0];
|
||||||
|
|
||||||
setSelectedModel(preferredModel?.model_name || '');
|
setSelectedModel(preferredModel?.model_name || '');
|
||||||
}, [availableModels, selectedModel]);
|
}, [availableModels, selectedModel, studioSettings.default_model]);
|
||||||
|
|
||||||
const tokenOptions = useMemo(() => {
|
const tokenOptions = useMemo(() => {
|
||||||
return tokens.map((token) => ({
|
return tokens.map((token) => ({
|
||||||
@@ -375,35 +450,36 @@ const ImageStudio = () => {
|
|||||||
}));
|
}));
|
||||||
}, [availableModels]);
|
}, [availableModels]);
|
||||||
|
|
||||||
const sizeRatioOptions = useMemo(() => {
|
|
||||||
return SIZE_OPTIONS.map((item) => ({
|
|
||||||
label:
|
|
||||||
item.value === 'auto'
|
|
||||||
? 'Auto'
|
|
||||||
: `${item.ratioLabel} · ${item.usage}`,
|
|
||||||
value: item.value,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sizeResolutionOptions = useMemo(() => {
|
const sizeResolutionOptions = useMemo(() => {
|
||||||
return SIZE_OPTIONS.map((item) => ({
|
return resolutionCandidates.map((value) => {
|
||||||
|
const item = getSizeMeta(value);
|
||||||
|
return {
|
||||||
label:
|
label:
|
||||||
item.value === 'auto'
|
item.value === 'auto'
|
||||||
? 'Auto(默认)'
|
? 'Auto(默认)'
|
||||||
: `${item.resolutionLabel} · 官方预设`,
|
: `${item.resolutionLabel} · 官方预设`,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
}));
|
};
|
||||||
}, []);
|
});
|
||||||
|
}, [resolutionCandidates]);
|
||||||
|
|
||||||
const qualityOptions = useMemo(() => {
|
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}`,
|
label: `${item.label} · ${item.description}`,
|
||||||
value: item.value,
|
value: item.value,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, [selectedModelSetting]);
|
||||||
|
|
||||||
const sizeMeta = getSizeMeta(selectedSize);
|
const sizeMeta = getSizeMeta(selectedSize);
|
||||||
const tokenModelLimits = parseTokenModelLimits(selectedToken);
|
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 noImageTokens = !loading && tokens.length === 0;
|
||||||
const noImageModels = !loading && imageGroupModels.length === 0;
|
const noImageModels = !loading && imageGroupModels.length === 0;
|
||||||
@@ -414,8 +490,57 @@ const ImageStudio = () => {
|
|||||||
!!selectedToken &&
|
!!selectedToken &&
|
||||||
availableModels.length === 0;
|
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 =
|
const canSubmit =
|
||||||
!submitting &&
|
!submitting &&
|
||||||
|
studioSettings.enabled &&
|
||||||
!!selectedTokenId &&
|
!!selectedTokenId &&
|
||||||
!!selectedModel &&
|
!!selectedModel &&
|
||||||
prompt.trim().length > 0 &&
|
prompt.trim().length > 0 &&
|
||||||
@@ -602,6 +727,10 @@ const ImageStudio = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!studioSettings.enabled) {
|
||||||
|
showError(t('图片制作功能暂未开放'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedTokenId) {
|
if (!selectedTokenId) {
|
||||||
showError(t('请先选择 image 分组令牌'));
|
showError(t('请先选择 image 分组令牌'));
|
||||||
return;
|
return;
|
||||||
@@ -704,7 +833,11 @@ const ImageStudio = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Tag color='blue'>{t('模型优先默认 gpt-image-2')}</Tag>
|
<Tag color='blue'>{t('模型优先默认 gpt-image-2')}</Tag>
|
||||||
<Tag color='amber'>{t('结果为临时图片,约 30 分钟后自动删除')}</Tag>
|
<Tag color='amber'>
|
||||||
|
{t('结果为临时图片,约 {{minutes}} 分钟后自动删除', {
|
||||||
|
minutes: studioSettings.retention_minutes || 30,
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -1028,12 +1161,17 @@ const ImageStudio = () => {
|
|||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<Text strong>{t('比例')}</Text>
|
<Text strong>{t('比例')}</Text>
|
||||||
<Select
|
<Select
|
||||||
value={selectedSize}
|
value={selectedRatio}
|
||||||
optionList={sizeRatioOptions}
|
optionList={sizeRatioOptions}
|
||||||
onChange={(value) => setSelectedSize(String(value || 'auto'))}
|
onChange={(value) => setSelectedRatio(String(value || 'Auto'))}
|
||||||
disabled={submitting}
|
disabled={submitting || !allowRatioSelection}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
{!allowRatioSelection && (
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('当前模型已固定比例,不允许在图片制作中手动切换。')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
@@ -1042,9 +1180,14 @@ const ImageStudio = () => {
|
|||||||
value={selectedSize}
|
value={selectedSize}
|
||||||
optionList={sizeResolutionOptions}
|
optionList={sizeResolutionOptions}
|
||||||
onChange={(value) => setSelectedSize(String(value || 'auto'))}
|
onChange={(value) => setSelectedSize(String(value || 'auto'))}
|
||||||
disabled={submitting}
|
disabled={submitting || !allowResolutionSelection}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
{!allowResolutionSelection && (
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('当前模型已固定分辨率,不允许在图片制作中手动切换。')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1055,9 +1198,14 @@ const ImageStudio = () => {
|
|||||||
value={selectedQuality}
|
value={selectedQuality}
|
||||||
optionList={qualityOptions}
|
optionList={qualityOptions}
|
||||||
onChange={(value) => setSelectedQuality(String(value || 'auto'))}
|
onChange={(value) => setSelectedQuality(String(value || 'auto'))}
|
||||||
disabled={submitting}
|
disabled={submitting || !allowQualitySelection}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
{!allowQualitySelection && (
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('当前模型已固定质量,不允许在图片制作中手动切换。')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
@@ -1228,16 +1376,18 @@ const ImageStudio = () => {
|
|||||||
>
|
>
|
||||||
{t('预览图片')}
|
{t('预览图片')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{studioSettings.allow_open_original && (
|
||||||
<a
|
<a
|
||||||
href={item.src}
|
href={item.taskResultUrl || item.rawUrl || item.src}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
className='text-sm text-sky-600 hover:text-sky-700'
|
className='text-sm text-sky-600 hover:text-sky-700'
|
||||||
>
|
>
|
||||||
{t('打开原图')}
|
{t('打开原图')}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={item.src}
|
href={item.taskResultUrl || item.rawUrl || item.src}
|
||||||
download={item.downloadName}
|
download={item.downloadName}
|
||||||
className='text-sm text-emerald-600 hover:text-emerald-700'
|
className='text-sm text-emerald-600 hover:text-emerald-700'
|
||||||
>
|
>
|
||||||
|
|||||||
396
web/src/pages/Setting/Drawing/SettingsImageStudio.jsx
Normal file
396
web/src/pages/Setting/Drawing/SettingsImageStudio.jsx
Normal file
@@ -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 (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Form values={inputs} getFormApi={(formApi) => (refForm.current = formApi)}>
|
||||||
|
<Form.Section text={t('图片制作设置')}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Switch
|
||||||
|
field='enabled'
|
||||||
|
label={t('启用图片制作')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
onChange={(value) => updateField('enabled', value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.Switch
|
||||||
|
field='allow_open_original'
|
||||||
|
label={t('允许打开原图')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
onChange={(value) => updateField('allow_open_original', value)}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Form.InputNumber
|
||||||
|
field='retention_minutes'
|
||||||
|
label={t('图片保留时长(分钟)')}
|
||||||
|
min={1}
|
||||||
|
max={240}
|
||||||
|
onChange={(value) => updateField('retention_minutes', Number(value || 30))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='default_model'
|
||||||
|
label={t('默认模型')}
|
||||||
|
optionList={modelOptions}
|
||||||
|
onChange={(value) => updateField('default_model', String(value || ''))}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='allowed_models'
|
||||||
|
label={t('允许使用的模型')}
|
||||||
|
optionList={modelOptions}
|
||||||
|
multiple
|
||||||
|
onChange={(value) => updateField('allowed_models', Array.isArray(value) ? value : [])}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text strong>{t('按模型控制参数')}</Text>
|
||||||
|
<div style={{ marginTop: 8, display: 'grid', gap: 12 }}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={modelName}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--semi-color-border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Tag color='blue'>{modelName}</Tag>
|
||||||
|
</div>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_enabled`}
|
||||||
|
label={t('启用该模型')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.enabled}
|
||||||
|
onChange={(value) => updateModelSetting(modelName, { enabled: value })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_generate`}
|
||||||
|
label={t('允许生成')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.allow_generate}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, { allow_generate: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_edit`}
|
||||||
|
label={t('允许编辑')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.allow_edit}
|
||||||
|
onChange={(value) => updateModelSetting(modelName, { allow_edit: value })}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_quality`}
|
||||||
|
label={t('允许选择质量')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.allow_quality}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, { allow_quality: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16} style={{ marginTop: 8 }}>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_ratio`}
|
||||||
|
label={t('允许切换比例')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.allow_ratio}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, { allow_ratio: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={6}>
|
||||||
|
<Form.Switch
|
||||||
|
field={`model_settings_${modelName}_resolution`}
|
||||||
|
label={t('允许切换分辨率')}
|
||||||
|
checkedText='|'
|
||||||
|
uncheckedText='〇'
|
||||||
|
initValue={setting.allow_resolution}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, { allow_resolution: value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16} style={{ marginTop: 8 }}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field={`model_settings_${modelName}_sizes`}
|
||||||
|
label={t('允许分辨率')}
|
||||||
|
multiple
|
||||||
|
optionList={SIZE_OPTIONS.map((item) => ({ label: item, value: item }))}
|
||||||
|
initValue={setting.allowed_sizes}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, {
|
||||||
|
allowed_sizes: Array.isArray(value) ? value : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field={`model_settings_${modelName}_qualities`}
|
||||||
|
label={t('允许质量')}
|
||||||
|
multiple
|
||||||
|
optionList={QUALITY_OPTIONS.map((item) => ({ label: item, value: item }))}
|
||||||
|
initValue={setting.allowed_qualities}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, {
|
||||||
|
allowed_qualities: Array.isArray(value) ? value : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16} style={{ marginTop: 8 }}>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field={`model_settings_${modelName}_default_size`}
|
||||||
|
label={t('默认分辨率')}
|
||||||
|
optionList={SIZE_OPTIONS.map((item) => ({ label: item, value: item }))}
|
||||||
|
initValue={setting.default_size}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, { default_size: String(value || 'auto') })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Form.Select
|
||||||
|
field={`model_settings_${modelName}_default_quality`}
|
||||||
|
label={t('默认质量')}
|
||||||
|
optionList={QUALITY_OPTIONS.map((item) => ({ label: item, value: item }))}
|
||||||
|
initValue={setting.default_quality}
|
||||||
|
onChange={(value) =>
|
||||||
|
updateModelSetting(modelName, {
|
||||||
|
default_quality: String(value || 'auto'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('说明:这里只控制图片制作页面可见和可选参数,不影响 image 分组本身的渠道与模型能力配置。')}
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<Text type='tertiary' size='small'>
|
||||||
|
{t('建议:先确定允许模型,再按模型限制生成/编辑、比例、分辨率、质量和默认值,避免前后端参数不一致。')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row style={{ marginTop: 16 }}>
|
||||||
|
<Button onClick={saveSettings}>{t('保存图片制作设置')}</Button>
|
||||||
|
</Row>
|
||||||
|
</Form.Section>
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -220,8 +220,8 @@ export default function GeneralSettings(props) {
|
|||||||
'general_setting.quota_display_type',
|
'general_setting.quota_display_type',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Select.Option value='USD'>USD ($)</Select.Option>
|
<Select.Option value='USD'>USD (¥)</Select.Option>
|
||||||
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
<Select.Option value='CNY'>CNY (¥)</Select.Option>
|
||||||
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
<Select.Option value='TOKENS'>Tokens</Select.Option>
|
||||||
<Select.Option value='CUSTOM'>
|
<Select.Option value='CUSTOM'>
|
||||||
{t('自定义货币')}
|
{t('自定义货币')}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='MinTopUp'
|
field='MinTopUp'
|
||||||
label={t('最低充值美元数量')}
|
label={t('最低充值美元数量')}
|
||||||
placeholder={t('例如:2,就是最低充值2$')}
|
placeholder={t('例如:2,就是最低充值2¥')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export default function SettingsPaymentGatewayCreem(props) {
|
|||||||
dataIndex: 'price',
|
dataIndex: 'price',
|
||||||
key: 'price',
|
key: 'price',
|
||||||
render: (price, record) =>
|
render: (price, record) =>
|
||||||
`${record.currency === 'EUR' ? '€' : '$'}${price}`,
|
`${record.currency === 'EUR' ? '€' : '¥'}${price}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('充值额度'),
|
title: t('充值额度'),
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function SettingsPaymentGateway(props) {
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='StripeMinTopUp'
|
field='StripeMinTopUp'
|
||||||
label={t('最低充值美元数量')}
|
label={t('最低充值美元数量')}
|
||||||
placeholder={t('例如:2,就是最低充值2$')}
|
placeholder={t('例如:2,就是最低充值2¥')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default function ModelPricingEditor({
|
|||||||
label={t('固定价格')}
|
label={t('固定价格')}
|
||||||
value={selectedModel.fixedPrice}
|
value={selectedModel.fixedPrice}
|
||||||
placeholder={t('输入每次调用价格')}
|
placeholder={t('输入每次调用价格')}
|
||||||
suffix={t('$/次')}
|
suffix={t('¥/次')}
|
||||||
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
onChange={(value) => handleNumericFieldChange('fixedPrice', value)}
|
||||||
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
extraText={t('适合 MJ / 任务类等按次收费模型。')}
|
||||||
/>
|
/>
|
||||||
@@ -428,7 +428,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('输入价格')}
|
label={t('输入价格')}
|
||||||
value={selectedModel.inputPrice}
|
value={selectedModel.inputPrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
|
onChange={(value) => handleNumericFieldChange('inputPrice', value)}
|
||||||
/>
|
/>
|
||||||
{selectedModel.completionRatioLocked ? (
|
{selectedModel.completionRatioLocked ? (
|
||||||
@@ -450,7 +450,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('补全价格')}
|
label={t('补全价格')}
|
||||||
value={selectedModel.completionPrice}
|
value={selectedModel.completionPrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleNumericFieldChange('completionPrice', value)
|
handleNumericFieldChange('completionPrice', value)
|
||||||
}
|
}
|
||||||
@@ -493,7 +493,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('缓存读取价格')}
|
label={t('缓存读取价格')}
|
||||||
value={selectedModel.cachePrice}
|
value={selectedModel.cachePrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
|
onChange={(value) => handleNumericFieldChange('cachePrice', value)}
|
||||||
headerAction={
|
headerAction={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -515,7 +515,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('缓存创建价格')}
|
label={t('缓存创建价格')}
|
||||||
value={selectedModel.createCachePrice}
|
value={selectedModel.createCachePrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleNumericFieldChange('createCachePrice', value)
|
handleNumericFieldChange('createCachePrice', value)
|
||||||
}
|
}
|
||||||
@@ -562,7 +562,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('图片输入价格')}
|
label={t('图片输入价格')}
|
||||||
value={selectedModel.imagePrice}
|
value={selectedModel.imagePrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
|
onChange={(value) => handleNumericFieldChange('imagePrice', value)}
|
||||||
headerAction={
|
headerAction={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -584,7 +584,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('音频输入价格')}
|
label={t('音频输入价格')}
|
||||||
value={selectedModel.audioInputPrice}
|
value={selectedModel.audioInputPrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleNumericFieldChange('audioInputPrice', value)
|
handleNumericFieldChange('audioInputPrice', value)
|
||||||
}
|
}
|
||||||
@@ -614,7 +614,7 @@ export default function ModelPricingEditor({
|
|||||||
<PriceInput
|
<PriceInput
|
||||||
label={t('音频补全价格')}
|
label={t('音频补全价格')}
|
||||||
value={selectedModel.audioOutputPrice}
|
value={selectedModel.audioOutputPrice}
|
||||||
placeholder={t('输入 $/1M tokens')}
|
placeholder={t('输入 ¥/1M tokens')}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
handleNumericFieldChange('audioOutputPrice', value)
|
handleNumericFieldChange('audioOutputPrice', value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { API, showError, showSuccess } from '../../../../helpers';
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
|
|
||||||
export const PAGE_SIZE = 10;
|
export const PAGE_SIZE = 10;
|
||||||
export const PRICE_SUFFIX = '$/1M tokens';
|
export const PRICE_SUFFIX = '¥/1M tokens';
|
||||||
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
const EMPTY_CANDIDATE_MODEL_NAMES = [];
|
||||||
|
|
||||||
const EMPTY_MODEL = {
|
const EMPTY_MODEL = {
|
||||||
@@ -245,7 +245,7 @@ export const getModelWarnings = (model, t) => {
|
|||||||
|
|
||||||
export const buildSummaryText = (model, t) => {
|
export const buildSummaryText = (model, t) => {
|
||||||
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
|
if (model.billingMode === 'per-request' && hasValue(model.fixedPrice)) {
|
||||||
return `${t('按次')} $${model.fixedPrice} / ${t('次')}`;
|
return `${t('按次')} ¥${model.fixedPrice} / ${t('次')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasValue(model.inputPrice)) {
|
if (hasValue(model.inputPrice)) {
|
||||||
@@ -259,7 +259,7 @@ export const buildSummaryText = (model, t) => {
|
|||||||
].filter(hasValue).length;
|
].filter(hasValue).length;
|
||||||
const extraLabel =
|
const extraLabel =
|
||||||
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
|
extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : '';
|
||||||
return `${t('输入')} $${model.inputPrice}${extraLabel}`;
|
return `${t('输入')} ¥${model.inputPrice}${extraLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return t('未设置价格');
|
return t('未设置价格');
|
||||||
|
|||||||
4
web/vite.config.js
vendored
4
web/vite.config.js
vendored
@@ -29,6 +29,10 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'./node_modules/@douyinfe/semi-ui/dist/css/semi.css',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user