From 35c1d5e3b208eaa0b4d6614ee972ba8bfa9f9648 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 11 May 2026 11:25:36 +0800
Subject: [PATCH 01/38] fix channel affinity cache validation
Add local Docker stack script for one-command builds.
---
bin/docker-local.sh | 416 ++++++++++++++++++
middleware/distributor.go | 29 +-
.../distributor_channel_affinity_test.go | 196 +++++++++
service/channel_affinity.go | 64 +++
4 files changed, 679 insertions(+), 26 deletions(-)
create mode 100755 bin/docker-local.sh
create mode 100644 middleware/distributor_channel_affinity_test.go
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
new file mode 100755
index 000000000000..fb1b3364b0ab
--- /dev/null
+++ b/bin/docker-local.sh
@@ -0,0 +1,416 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+cd "${ROOT_DIR}"
+
+PROJECT_NAME="${PROJECT_NAME:-new-api-local}"
+IMAGE_NAME="${IMAGE_NAME:-new-api:local}"
+CONTAINER_NAME="${CONTAINER_NAME:-${PROJECT_NAME}-app}"
+POSTGRES_CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-${PROJECT_NAME}-postgres}"
+REDIS_CONTAINER_NAME="${REDIS_CONTAINER_NAME:-${PROJECT_NAME}-redis}"
+NETWORK_NAME="${NETWORK_NAME:-${PROJECT_NAME}-network}"
+POSTGRES_VOLUME="${POSTGRES_VOLUME:-${PROJECT_NAME}-postgres-data}"
+REDIS_VOLUME="${REDIS_VOLUME:-${PROJECT_NAME}-redis-data}"
+APP_DATA_VOLUME="${APP_DATA_VOLUME:-${PROJECT_NAME}-app-data}"
+
+HOST_PORT="${HOST_PORT:-${PORT:-3000}}"
+APP_PORT="${APP_PORT:-3000}"
+POSTGRES_HOST_PORT="${POSTGRES_HOST_PORT:-}"
+REDIS_HOST_PORT="${REDIS_HOST_PORT:-}"
+LOCAL_TZ="${TZ:-Asia/Shanghai}"
+ENV_FILE="${ENV_FILE:-}"
+FOLLOW_LOGS="${FOLLOW_LOGS:-0}"
+NO_CACHE="${NO_CACHE:-0}"
+PLATFORM="${PLATFORM:-}"
+ACTION="${1:-up}"
+
+STATE_DIR="${STATE_DIR:-${ROOT_DIR}/data/docker-local}"
+LOG_DIR="${LOG_DIR:-${ROOT_DIR}/logs/docker-local}"
+SECRETS_FILE="${SECRETS_FILE:-${STATE_DIR}/.env.generated}"
+
+POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:15-alpine}"
+REDIS_IMAGE="${REDIS_IMAGE:-redis:7-alpine}"
+POSTGRES_DB="${POSTGRES_DB:-new-api}"
+POSTGRES_USER="${POSTGRES_USER:-newapi}"
+POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
+REDIS_PASSWORD="${REDIS_PASSWORD:-}"
+SESSION_SECRET="${SESSION_SECRET:-}"
+CRYPTO_SECRET="${CRYPTO_SECRET:-}"
+NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}"
+BUILD_ON_UP="${BUILD_ON_UP:-1}"
+
+usage() {
+ cat <<'USAGE'
+Usage:
+ bash bin/docker-local.sh [up|build|run|stop|logs|status|clean|help]
+
+Default action:
+ up Build app image, then start PostgreSQL, Redis, and new-api.
+
+No manual config is required. The script persists generated secrets in:
+ ./data/docker-local/.env.generated
+
+Common environment overrides:
+ PROJECT_NAME=new-api-local Prefix for containers/network/volumes
+ IMAGE_NAME=new-api:local Docker image tag for the app
+ HOST_PORT=3000 Host port for new-api
+ POSTGRES_HOST_PORT=5432 Optional host port for PostgreSQL
+ REDIS_HOST_PORT=6379 Optional host port for Redis
+ BUILD_ON_UP=0 Skip docker build during up
+ NO_CACHE=1 Build without Docker cache
+ PLATFORM=linux/amd64 Optional docker build --platform value
+ FOLLOW_LOGS=1 Follow app logs after starting
+ ENV_FILE=.env.local Optional extra env file for the app
+
+Advanced overrides:
+ POSTGRES_PASSWORD=... Override generated PostgreSQL password
+ REDIS_PASSWORD=... Override generated Redis password
+ SESSION_SECRET=... Override generated session secret
+ CRYPTO_SECRET=... Override generated crypto secret
+
+Examples:
+ bash bin/docker-local.sh
+ HOST_PORT=3001 bash bin/docker-local.sh up
+ BUILD_ON_UP=0 bash bin/docker-local.sh up
+ bash bin/docker-local.sh logs
+ bash bin/docker-local.sh status
+ bash bin/docker-local.sh stop
+ bash bin/docker-local.sh clean
+USAGE
+}
+
+log() {
+ printf '\033[1;34m==>\033[0m %s\n' "$*"
+}
+
+warn() {
+ printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2
+}
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required command: $1" >&2
+ exit 1
+ fi
+}
+
+random_secret() {
+ if command -v openssl >/dev/null 2>&1; then
+ openssl rand -hex 32
+ else
+ LC_ALL=C tr -dc 'A-Za-z0-9' "${SECRETS_FILE}" </dev/null 2>&1
+}
+
+container_running() {
+ [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]]
+}
+
+remove_container_if_exists() {
+ local name="$1"
+ if container_exists "${name}"; then
+ log "Removing existing container ${name}"
+ docker rm -f "${name}" >/dev/null
+ fi
+}
+
+ensure_network() {
+ if ! docker network inspect "${NETWORK_NAME}" >/dev/null 2>&1; then
+ log "Creating Docker network ${NETWORK_NAME}"
+ docker network create "${NETWORK_NAME}" >/dev/null
+ fi
+}
+
+build_image() {
+ require_cmd docker
+
+ local build_args=()
+ if [[ "${NO_CACHE}" == "1" || "${NO_CACHE}" == "true" ]]; then
+ build_args+=(--no-cache)
+ fi
+ if [[ -n "${PLATFORM}" ]]; then
+ build_args+=(--platform "${PLATFORM}")
+ fi
+
+ log "Building Docker image ${IMAGE_NAME}"
+ DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" docker build \
+ "${build_args[@]}" \
+ -f "${ROOT_DIR}/Dockerfile" \
+ -t "${IMAGE_NAME}" \
+ "${ROOT_DIR}"
+}
+
+start_postgres() {
+ require_cmd docker
+ ensure_network
+ ensure_secrets
+
+ if container_running "${POSTGRES_CONTAINER_NAME}"; then
+ log "PostgreSQL already running: ${POSTGRES_CONTAINER_NAME}"
+ return
+ fi
+ remove_container_if_exists "${POSTGRES_CONTAINER_NAME}"
+
+ local port_args=()
+ if [[ -n "${POSTGRES_HOST_PORT}" ]]; then
+ port_args=(-p "${POSTGRES_HOST_PORT}:5432")
+ fi
+
+ log "Starting PostgreSQL ${POSTGRES_CONTAINER_NAME}"
+ docker run -d \
+ --name "${POSTGRES_CONTAINER_NAME}" \
+ --restart unless-stopped \
+ --network "${NETWORK_NAME}" \
+ "${port_args[@]}" \
+ -v "${POSTGRES_VOLUME}:/var/lib/postgresql/data" \
+ -e "POSTGRES_DB=${POSTGRES_DB}" \
+ -e "POSTGRES_USER=${POSTGRES_USER}" \
+ -e "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \
+ -e "TZ=${LOCAL_TZ}" \
+ "${POSTGRES_IMAGE}" >/dev/null
+}
+
+start_redis() {
+ require_cmd docker
+ ensure_network
+ ensure_secrets
+
+ if container_running "${REDIS_CONTAINER_NAME}"; then
+ log "Redis already running: ${REDIS_CONTAINER_NAME}"
+ return
+ fi
+ remove_container_if_exists "${REDIS_CONTAINER_NAME}"
+
+ local port_args=()
+ if [[ -n "${REDIS_HOST_PORT}" ]]; then
+ port_args=(-p "${REDIS_HOST_PORT}:6379")
+ fi
+
+ log "Starting Redis ${REDIS_CONTAINER_NAME}"
+ docker run -d \
+ --name "${REDIS_CONTAINER_NAME}" \
+ --restart unless-stopped \
+ --network "${NETWORK_NAME}" \
+ "${port_args[@]}" \
+ -v "${REDIS_VOLUME}:/data" \
+ -e "TZ=${LOCAL_TZ}" \
+ "${REDIS_IMAGE}" \
+ redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}" >/dev/null
+}
+
+wait_for_postgres() {
+ log "Waiting for PostgreSQL"
+ local i
+ for i in {1..60}; do
+ if docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; then
+ return
+ fi
+ sleep 1
+ done
+ echo "PostgreSQL did not become ready in time" >&2
+ docker logs "${POSTGRES_CONTAINER_NAME}" >&2 || true
+ exit 1
+}
+
+wait_for_redis() {
+ log "Waiting for Redis"
+ local i
+ for i in {1..60}; do
+ if docker exec "${REDIS_CONTAINER_NAME}" redis-cli -a "${REDIS_PASSWORD}" ping >/dev/null 2>&1; then
+ return
+ fi
+ sleep 1
+ done
+ echo "Redis did not become ready in time" >&2
+ docker logs "${REDIS_CONTAINER_NAME}" >&2 || true
+ exit 1
+}
+
+run_container() {
+ require_cmd docker
+ ensure_network
+ ensure_secrets
+ start_postgres
+ start_redis
+ wait_for_postgres
+ wait_for_redis
+ mkdir -p "${LOG_DIR}"
+
+ remove_container_if_exists "${CONTAINER_NAME}"
+
+ local sql_dsn="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_CONTAINER_NAME}:5432/${POSTGRES_DB}"
+ local redis_dsn="redis://:${REDIS_PASSWORD}@${REDIS_CONTAINER_NAME}:6379/0"
+ local env_args=(
+ -e "TZ=${LOCAL_TZ}"
+ -e "PORT=${APP_PORT}"
+ -e "SQL_DSN=${SQL_DSN:-${sql_dsn}}"
+ -e "REDIS_CONN_STRING=${REDIS_CONN_STRING:-${redis_dsn}}"
+ -e "SESSION_SECRET=${SESSION_SECRET}"
+ -e "CRYPTO_SECRET=${CRYPTO_SECRET}"
+ -e "ERROR_LOG_ENABLED=${ERROR_LOG_ENABLED:-true}"
+ -e "BATCH_UPDATE_ENABLED=${BATCH_UPDATE_ENABLED:-true}"
+ -e "MEMORY_CACHE_ENABLED=${MEMORY_CACHE_ENABLED:-true}"
+ -e "SYNC_FREQUENCY=${SYNC_FREQUENCY:-60}"
+ -e "NODE_NAME=${NODE_NAME}"
+ )
+
+ local pass_env_vars=(
+ LOG_SQL_DSN
+ RELAY_TIMEOUT
+ STREAMING_TIMEOUT
+ CHANNEL_UPDATE_FREQUENCY
+ GENERATE_DEFAULT_TOKEN
+ FRONTEND_BASE_URL
+ TRUSTED_REDIRECT_DOMAINS
+ )
+ local name
+ for name in "${pass_env_vars[@]}"; do
+ if [[ -n "${!name:-}" ]]; then
+ env_args+=(-e "${name}=${!name}")
+ fi
+ done
+
+ if [[ -n "${ENV_FILE}" ]]; then
+ if [[ ! -f "${ENV_FILE}" ]]; then
+ echo "ENV_FILE does not exist: ${ENV_FILE}" >&2
+ exit 1
+ fi
+ env_args+=(--env-file "${ENV_FILE}")
+ fi
+
+ log "Starting app ${CONTAINER_NAME} on http://localhost:${HOST_PORT}"
+ docker run -d \
+ --name "${CONTAINER_NAME}" \
+ --restart unless-stopped \
+ --network "${NETWORK_NAME}" \
+ -p "${HOST_PORT}:${APP_PORT}" \
+ -v "${APP_DATA_VOLUME}:/data" \
+ -v "${LOG_DIR}:/app/logs" \
+ "${env_args[@]}" \
+ "${IMAGE_NAME}" \
+ --log-dir /app/logs >/dev/null
+
+ log "Secrets file: ${SECRETS_FILE}"
+ log "PostgreSQL volume: ${POSTGRES_VOLUME}"
+ log "Redis volume: ${REDIS_VOLUME}"
+ log "App data volume: ${APP_DATA_VOLUME}"
+ log "Logs dir: ${LOG_DIR}"
+ log "Open: http://localhost:${HOST_PORT}"
+
+ if [[ "${FOLLOW_LOGS}" == "1" || "${FOLLOW_LOGS}" == "true" ]]; then
+ docker logs -f "${CONTAINER_NAME}"
+ fi
+}
+
+stop_container() {
+ require_cmd docker
+ remove_container_if_exists "${CONTAINER_NAME}"
+ remove_container_if_exists "${REDIS_CONTAINER_NAME}"
+ remove_container_if_exists "${POSTGRES_CONTAINER_NAME}"
+}
+
+show_logs() {
+ require_cmd docker
+ local target="${2:-app}"
+ case "${target}" in
+ app) docker logs -f "${CONTAINER_NAME}" ;;
+ postgres|pg) docker logs -f "${POSTGRES_CONTAINER_NAME}" ;;
+ redis) docker logs -f "${REDIS_CONTAINER_NAME}" ;;
+ *) echo "Unknown logs target: ${target} (use app|postgres|redis)" >&2; exit 1 ;;
+ esac
+}
+
+show_status() {
+ require_cmd docker
+ docker ps -a \
+ --filter "name=^/${CONTAINER_NAME}$" \
+ --filter "name=^/${POSTGRES_CONTAINER_NAME}$" \
+ --filter "name=^/${REDIS_CONTAINER_NAME}$"
+}
+
+clean_all() {
+ stop_container
+ log "Removing image ${IMAGE_NAME} if it exists"
+ docker image rm "${IMAGE_NAME}" >/dev/null 2>&1 || true
+
+ if [[ "${KEEP_VOLUMES:-1}" == "0" || "${KEEP_VOLUMES:-1}" == "false" ]]; then
+ warn "Removing persistent volumes and generated secrets"
+ docker volume rm "${POSTGRES_VOLUME}" "${REDIS_VOLUME}" "${APP_DATA_VOLUME}" >/dev/null 2>&1 || true
+ rm -f "${SECRETS_FILE}"
+ else
+ log "Keeping volumes. Set KEEP_VOLUMES=0 bash bin/docker-local.sh clean to remove them."
+ fi
+}
+
+case "${ACTION}" in
+ up)
+ if [[ "${BUILD_ON_UP}" == "1" || "${BUILD_ON_UP}" == "true" ]]; then
+ build_image
+ fi
+ run_container
+ ;;
+ build)
+ build_image
+ ;;
+ run)
+ run_container
+ ;;
+ stop)
+ stop_container
+ ;;
+ logs)
+ show_logs "$@"
+ ;;
+ status)
+ show_status
+ ;;
+ clean)
+ clean_all
+ ;;
+ help|-h|--help)
+ usage
+ ;;
+ *)
+ echo "Unknown action: ${ACTION}" >&2
+ usage
+ exit 1
+ ;;
+esac
diff --git a/middleware/distributor.go b/middleware/distributor.go
index 2263fae3fae5..85ae53859b18 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -99,32 +99,9 @@ func Distribute() func(c *gin.Context) {
}
}
- if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
- preferred, err := model.CacheGetChannel(preferredChannelID)
- if err == nil && preferred != nil {
- if preferred.Status != common.ChannelStatusEnabled {
- if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
- abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
- return
- }
- } else if usingGroup == "auto" {
- userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
- autoGroups := service.GetUserAutoGroup(userGroup)
- for _, g := range autoGroups {
- if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
- selectGroup = g
- common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
- channel = preferred
- service.MarkChannelAffinityUsed(c, g, preferred.Id)
- break
- }
- }
- } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
- channel = preferred
- selectGroup = usingGroup
- service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
- }
- }
+ if preferred, selectedGroup, found := service.GetUsablePreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
+ channel = preferred
+ selectGroup = selectedGroup
}
if channel == nil {
diff --git a/middleware/distributor_channel_affinity_test.go b/middleware/distributor_channel_affinity_test.go
new file mode 100644
index 000000000000..07f7f53cd40b
--- /dev/null
+++ b/middleware/distributor_channel_affinity_test.go
@@ -0,0 +1,196 @@
+package middleware
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupDistributorAffinityTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+
+ originalDB := model.DB
+ originalLogDB := model.LOG_DB
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ originalRedisEnabled := common.RedisEnabled
+ originalUsingSQLite := common.UsingSQLite
+ originalUsingMySQL := common.UsingMySQL
+ originalUsingPostgreSQL := common.UsingPostgreSQL
+
+ gin.SetMode(gin.TestMode)
+ common.MemoryCacheEnabled = true
+ common.RedisEnabled = false
+ common.UsingSQLite = true
+ common.UsingMySQL = false
+ common.UsingPostgreSQL = false
+
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+
+ model.DB = db
+ model.LOG_DB = db
+ require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{}))
+ service.ClearChannelAffinityCacheAll()
+
+ t.Cleanup(func() {
+ service.ClearChannelAffinityCacheAll()
+ _ = db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Ability{}).Error
+ _ = db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Channel{}).Error
+ model.InitChannelCache()
+
+ model.DB = originalDB
+ model.LOG_DB = originalLogDB
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ common.RedisEnabled = originalRedisEnabled
+ common.UsingSQLite = originalUsingSQLite
+ common.UsingMySQL = originalUsingMySQL
+ common.UsingPostgreSQL = originalUsingPostgreSQL
+ if originalMemoryCacheEnabled && originalDB != nil {
+ model.InitChannelCache()
+ }
+ if sqlDB, err := db.DB(); err == nil {
+ _ = sqlDB.Close()
+ }
+ })
+
+ return db
+}
+
+func seedDistributorAffinityChannel(t *testing.T, db *gorm.DB, name string, status int, priority int64) *model.Channel {
+ t.Helper()
+ return seedDistributorAffinityChannelForModel(t, db, name, status, priority, "gpt-5")
+}
+
+func seedDistributorAffinityChannelForModel(t *testing.T, db *gorm.DB, name string, status int, priority int64, modelName string) *model.Channel {
+ t.Helper()
+
+ weight := uint(100)
+ autoBan := 1
+ baseURL := "https://example.com"
+ channel := &model.Channel{
+ Type: constant.ChannelTypeOpenAI,
+ Key: "sk-" + name,
+ Status: status,
+ Name: name,
+ Weight: &weight,
+ BaseURL: &baseURL,
+ Models: modelName,
+ Group: "default",
+ Priority: &priority,
+ AutoBan: &autoBan,
+ }
+ require.NoError(t, db.Create(channel).Error)
+ require.NoError(t, db.Create(&model.Ability{
+ Group: "default",
+ Model: modelName,
+ ChannelId: channel.Id,
+ Enabled: status == common.ChannelStatusEnabled,
+ Priority: &priority,
+ Weight: weight,
+ }).Error)
+ return channel
+}
+
+func buildAffinityRequestContext(t *testing.T, body string) *gin.Context {
+ t.Helper()
+
+ rec := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(rec)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+ common.SetContextKey(ctx, constant.ContextKeyUsingGroup, "default")
+ common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
+ return ctx
+}
+
+func serveAffinityResponsesRequest(t *testing.T, body string) (int, int) {
+ t.Helper()
+
+ var selectedChannelID int
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ common.SetContextKey(c, constant.ContextKeyUsingGroup, "default")
+ common.SetContextKey(c, constant.ContextKeyUserGroup, "default")
+ common.SetContextKey(c, constant.ContextKeyTokenModelLimitEnabled, false)
+ c.Next()
+ })
+ router.POST("/v1/responses", Distribute(), func(c *gin.Context) {
+ selectedChannelID = common.GetContextKeyInt(c, constant.ContextKeyChannelId)
+ c.Status(http.StatusOK)
+ })
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(recorder, request)
+ return recorder.Code, selectedChannelID
+}
+
+func TestDistributeInvalidatesDisabledAffinityChannelAndReselects(t *testing.T) {
+ db := setupDistributorAffinityTestDB(t)
+
+ disabled := seedDistributorAffinityChannel(t, db, "affinity-disabled", common.ChannelStatusManuallyDisabled, 100)
+ available := seedDistributorAffinityChannel(t, db, "affinity-available", common.ChannelStatusEnabled, 90)
+ model.InitChannelCache()
+
+ body := `{"model":"gpt-5","prompt_cache_key":"affinity-session-disabled"}`
+ bindCtx := buildAffinityRequestContext(t, body)
+ _, found := service.GetPreferredChannelByAffinity(bindCtx, "gpt-5", "default")
+ require.False(t, found)
+ service.RecordChannelAffinity(bindCtx, disabled.Id)
+
+ checkCtx := buildAffinityRequestContext(t, body)
+ cachedChannelID, found := service.GetPreferredChannelByAffinity(checkCtx, "gpt-5", "default")
+ require.True(t, found)
+ require.Equal(t, disabled.Id, cachedChannelID)
+
+ statusCode, selectedChannelID := serveAffinityResponsesRequest(t, body)
+ require.Equal(t, http.StatusOK, statusCode)
+ require.Equal(t, available.Id, selectedChannelID)
+
+ refreshedCtx := buildAffinityRequestContext(t, body)
+ cachedChannelID, found = service.GetPreferredChannelByAffinity(refreshedCtx, "gpt-5", "default")
+ require.True(t, found)
+ require.Equal(t, available.Id, cachedChannelID)
+}
+
+func TestDistributeInvalidatesModelMismatchedAffinityChannelAndReselects(t *testing.T) {
+ db := setupDistributorAffinityTestDB(t)
+
+ mismatched := seedDistributorAffinityChannelForModel(t, db, "affinity-gpt5", common.ChannelStatusEnabled, 100, "gpt-5")
+ available := seedDistributorAffinityChannelForModel(t, db, "affinity-gpt4", common.ChannelStatusEnabled, 90, "gpt-4")
+ model.InitChannelCache()
+
+ cacheBody := `{"model":"gpt-5","prompt_cache_key":"affinity-session-model-mismatch"}`
+ bindCtx := buildAffinityRequestContext(t, cacheBody)
+ _, found := service.GetPreferredChannelByAffinity(bindCtx, "gpt-5", "default")
+ require.False(t, found)
+ service.RecordChannelAffinity(bindCtx, mismatched.Id)
+
+ requestBody := `{"model":"gpt-4","prompt_cache_key":"affinity-session-model-mismatch"}`
+ checkCtx := buildAffinityRequestContext(t, requestBody)
+ cachedChannelID, found := service.GetPreferredChannelByAffinity(checkCtx, "gpt-4", "default")
+ require.True(t, found)
+ require.Equal(t, mismatched.Id, cachedChannelID)
+
+ statusCode, selectedChannelID := serveAffinityResponsesRequest(t, requestBody)
+ require.Equal(t, http.StatusOK, statusCode)
+ require.Equal(t, available.Id, selectedChannelID)
+
+ refreshedCtx := buildAffinityRequestContext(t, requestBody)
+ cachedChannelID, found = service.GetPreferredChannelByAffinity(refreshedCtx, "gpt-4", "default")
+ require.True(t, found)
+ require.Equal(t, available.Id, cachedChannelID)
+}
diff --git a/service/channel_affinity.go b/service/channel_affinity.go
index f16c350bb14e..93e12369dfd4 100644
--- a/service/channel_affinity.go
+++ b/service/channel_affinity.go
@@ -10,7 +10,9 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/cachex"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
@@ -623,6 +625,68 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
return 0, false
}
+func GetUsablePreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (*model.Channel, string, bool) {
+ channelID, found := GetPreferredChannelByAffinity(c, modelName, usingGroup)
+ if !found {
+ return nil, "", false
+ }
+
+ preferred, err := model.CacheGetChannel(channelID)
+ if err != nil || preferred == nil {
+ DiscardChannelAffinityCacheForContext(c)
+ return nil, "", false
+ }
+
+ selectedGroup, ok := validateChannelAffinityHit(c, preferred, modelName, usingGroup)
+ if !ok {
+ DiscardChannelAffinityCacheForContext(c)
+ return nil, "", false
+ }
+
+ MarkChannelAffinityUsed(c, selectedGroup, preferred.Id)
+ return preferred, selectedGroup, true
+}
+
+func validateChannelAffinityHit(c *gin.Context, channel *model.Channel, modelName string, usingGroup string) (string, bool) {
+ if channel == nil || channel.Id <= 0 {
+ return "", false
+ }
+ if channel.Status != common.ChannelStatusEnabled {
+ return "", false
+ }
+
+ if usingGroup == "auto" {
+ userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
+ autoGroups := GetUserAutoGroup(userGroup)
+ for _, group := range autoGroups {
+ if model.IsChannelEnabledForGroupModel(group, modelName, channel.Id) {
+ common.SetContextKey(c, constant.ContextKeyAutoGroup, group)
+ return group, true
+ }
+ }
+ return "", false
+ }
+
+ if model.IsChannelEnabledForGroupModel(usingGroup, modelName, channel.Id) {
+ return usingGroup, true
+ }
+ return "", false
+}
+
+func DiscardChannelAffinityCacheForContext(c *gin.Context) bool {
+ cacheKey, _, ok := getChannelAffinityContext(c)
+ if !ok || cacheKey == "" {
+ return false
+ }
+ cache := getChannelAffinityCache()
+ deleted, err := cache.DeleteMany([]string{cacheKey})
+ if err != nil {
+ common.SysError(fmt.Sprintf("channel affinity cache delete failed: key=%s, err=%v", cacheKey, err))
+ return false
+ }
+ return deleted[cacheKey]
+}
+
func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
if c == nil {
return false
From 4135155cbb75b33d36f52c7dced37a1c03af041a Mon Sep 17 00:00:00 2001
From: z23cc
Date: Thu, 28 May 2026 13:43:25 +0800
Subject: [PATCH 02/38] chore: default local docker deployment to new frontend
---
bin/docker-local.sh | 37 +++++++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
index fb1b3364b0ab..313571643b8b 100755
--- a/bin/docker-local.sh
+++ b/bin/docker-local.sh
@@ -40,6 +40,9 @@ SESSION_SECRET="${SESSION_SECRET:-}"
CRYPTO_SECRET="${CRYPTO_SECRET:-}"
NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}"
BUILD_ON_UP="${BUILD_ON_UP:-1}"
+if [[ -z "${FRONTEND_THEME+x}" ]]; then
+ FRONTEND_THEME="default"
+fi
usage() {
cat <<'USAGE'
@@ -63,6 +66,7 @@ Common environment overrides:
PLATFORM=linux/amd64 Optional docker build --platform value
FOLLOW_LOGS=1 Follow app logs after starting
ENV_FILE=.env.local Optional extra env file for the app
+ FRONTEND_THEME=default Frontend theme for local deployment (default|classic; empty to skip)
Advanced overrides:
POSTGRES_PASSWORD=... Override generated PostgreSQL password
@@ -264,6 +268,37 @@ wait_for_redis() {
exit 1
}
+wait_for_options_table() {
+ local i
+ for i in {1..60}; do
+ if docker exec "${POSTGRES_CONTAINER_NAME}" psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT to_regclass('public.options') IS NOT NULL" 2>/dev/null | grep -q "t"; then
+ return
+ fi
+ sleep 1
+ done
+ echo "options table did not become ready in time" >&2
+ docker logs "${CONTAINER_NAME}" >&2 || true
+ exit 1
+}
+
+apply_frontend_theme() {
+ if [[ -z "${FRONTEND_THEME}" ]]; then
+ return
+ fi
+ if [[ "${FRONTEND_THEME}" != "default" && "${FRONTEND_THEME}" != "classic" ]]; then
+ echo "Invalid FRONTEND_THEME: ${FRONTEND_THEME} (use default|classic, or empty to skip)" >&2
+ exit 1
+ fi
+
+ log "Setting frontend theme to ${FRONTEND_THEME}"
+ wait_for_options_table
+ docker exec "${POSTGRES_CONTAINER_NAME}" psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=1 -c \
+ "INSERT INTO options (\"key\", \"value\") VALUES ('theme.frontend','${FRONTEND_THEME}') ON CONFLICT (\"key\") DO UPDATE SET \"value\" = EXCLUDED.\"value\";" >/dev/null
+
+ log "Restarting app to apply frontend theme"
+ docker restart "${CONTAINER_NAME}" >/dev/null
+}
+
run_container() {
require_cmd docker
ensure_network
@@ -328,6 +363,8 @@ run_container() {
"${IMAGE_NAME}" \
--log-dir /app/logs >/dev/null
+ apply_frontend_theme
+
log "Secrets file: ${SECRETS_FILE}"
log "PostgreSQL volume: ${POSTGRES_VOLUME}"
log "Redis volume: ${REDIS_VOLUME}"
From 8b8052e1bd1ae5546a266d5ef4159fe0b82e50d5 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Fri, 29 May 2026 21:09:10 +0800
Subject: [PATCH 03/38] feat(nav): dynamic database-backed navigation system
with multi-lingual fallback and visibility rules
---
.gitignore | 1 +
controller/navigation.go | 400 +++++++++
docker-compose.yml | 2 +-
middleware/auth.go | 10 +
model/main.go | 143 ++++
model/navigation.go | 77 ++
router/api-router.go | 19 +
service/navigation.go | 342 ++++++++
service/navigation_test.go | 242 ++++++
service/waffo_pancake_test.go | 5 +
.../layout/components/public-navigation.tsx | 133 ++-
.../components/layout/components/top-nav.tsx | 226 +++--
web/default/src/components/layout/types.ts | 2 +
.../maintenance/header-navigation-section.tsx | 777 ++++++++++++------
.../system-settings/site/section-registry.tsx | 11 +-
.../components/usage-logs-mobile-card.tsx | 4 +-
web/default/src/hooks/use-top-nav-links.ts | 137 ++-
web/default/src/lib/nav-modules.ts | 23 +
18 files changed, 2127 insertions(+), 427 deletions(-)
create mode 100644 controller/navigation.go
create mode 100644 model/navigation.go
create mode 100644 service/navigation.go
create mode 100644 service/navigation_test.go
diff --git a/.gitignore b/.gitignore
index bbc5717e4727..32cb4c653f9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@ data/
.test
token_estimator_test.go
skills-lock.json
+query-newapi/*
\ No newline at end of file
diff --git a/controller/navigation.go b/controller/navigation.go
new file mode 100644
index 000000000000..d7b3cc7e6d9f
--- /dev/null
+++ b/controller/navigation.go
@@ -0,0 +1,400 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+package controller
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+)
+
+// GetNavigationTree 用户侧获取导航树 API
+func GetNavigationTree(c *gin.Context) {
+ menuKey := c.DefaultQuery("menu_key", "default_web_top")
+
+ // 从 Context 中提取语言,默认为 zh-CN
+ locale := c.GetString("lang")
+ if locale == "" {
+ locale = c.DefaultQuery("lang", "zh-CN")
+ }
+
+ // 提取用户登录态与权限
+ userID := c.GetInt("id")
+ userRole := c.GetInt("role")
+ userGroup := c.GetString("group")
+
+ isAuthenticated := userID > 0
+
+ tree, err := service.NavService.GetVisibleNavigationTree(menuKey, locale, userRole, userGroup, isAuthenticated)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": tree,
+ })
+}
+
+// ================= 管理侧菜单 CRUD 接口 =================
+
+// AdminGetMenus 获取所有菜单容器列表
+func AdminGetMenus(c *gin.Context) {
+ var menus []model.NavigationMenu
+ if err := model.DB.Order("id asc").Find(&menus).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": menus})
+}
+
+// AdminCreateMenu 创建新的菜单配置
+func AdminCreateMenu(c *gin.Context) {
+ var menu model.NavigationMenu
+ if err := c.ShouldBindJSON(&menu); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ menu.IsSystem = false // 管理员手工创建的绝非系统菜单
+ if err := model.DB.Create(&menu).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": menu})
+}
+
+// AdminUpdateMenu 更新菜单元数据
+func AdminUpdateMenu(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu id"})
+ return
+ }
+
+ var menu model.NavigationMenu
+ if err := model.DB.First(&menu, id).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu not found"})
+ return
+ }
+
+ var input model.NavigationMenu
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ // 仅允许修改名称、启用状态
+ menu.Name = input.Name
+ menu.Enabled = input.Enabled
+
+ if err := model.DB.Save(&menu).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": menu})
+}
+
+// AdminDeleteMenu 删除非系统级菜单
+func AdminDeleteMenu(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu id"})
+ return
+ }
+
+ var menu model.NavigationMenu
+ if err := model.DB.First(&menu, id).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu not found"})
+ return
+ }
+
+ if menu.IsSystem {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "system menu cannot be deleted"})
+ return
+ }
+
+ // 在事务中级联清理 Menu 关联的所有 Item
+ err = model.DB.Transaction(func(tx *gorm.DB) error {
+ var items []model.NavigationItem
+ if err := tx.Where("menu_id = ?", menu.ID).Find(&items).Error; err != nil {
+ return err
+ }
+
+ for _, item := range items {
+ // 触发级联物理删除 Translations & Rules
+ if err := tx.Delete(&item).Error; err != nil {
+ return err
+ }
+ }
+
+ return tx.Delete(&menu).Error
+ })
+
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "menu deleted successfully"})
+}
+
+// ================= 管理侧菜单节点 CRUD 接口 =================
+
+// AdminGetItems 获取某个菜单下所有的平铺节点(含级联预加载翻译和规则,由前端还原树)
+func AdminGetItems(c *gin.Context) {
+ menuIDStr := c.Query("menu_id")
+ if menuIDStr == "" {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu_id query parameter is required"})
+ return
+ }
+
+ menuID, err := strconv.Atoi(menuIDStr)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu_id"})
+ return
+ }
+
+ var items []model.NavigationItem
+ err = model.DB.Where("menu_id = ?", menuID).
+ Order("sort_order asc, id asc").
+ Preload("Translations").
+ Preload("Rules").
+ Find(&items).Error
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": items})
+}
+
+// AdminCreateItem 创建菜单节点(包含多语言和可见性规则的一体化保存)
+func AdminCreateItem(c *gin.Context) {
+ var item model.NavigationItem
+ if err := c.ShouldBindJSON(&item); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ // 1. URL 协议安全性拦截校验(防 XSS 注入)
+ if err := service.NavService.ValidateItemURL(item.Type, item.URL); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ // 2. 事务级联创建节点及其子集合
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Omit("Translations", "Rules").Create(&item).Error; err != nil {
+ return err
+ }
+
+ // 保存多语言
+ for i := range item.Translations {
+ item.Translations[i].ItemID = item.ID
+ if err := tx.Create(&item.Translations[i]).Error; err != nil {
+ return err
+ }
+ }
+
+ // 保存可见性规则
+ for i := range item.Rules {
+ item.Rules[i].ItemID = item.ID
+ if err := tx.Create(&item.Rules[i]).Error; err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
+}
+
+// AdminUpdateItem 更新菜单节点及其子属性(采用 FullSaveAssociations 完整事务更新)
+func AdminUpdateItem(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid item id"})
+ return
+ }
+
+ var item model.NavigationItem
+ if err := model.DB.Preload("Translations").Preload("Rules").First(&item, id).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "item not found"})
+ return
+ }
+
+ var input model.NavigationItem
+ if err := c.ShouldBindJSON(&input); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ // 1. 安全性 URL 校验
+ if err := service.NavService.ValidateItemURL(input.Type, input.URL); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ // 2. 级联事务全量覆盖更新
+ err = model.DB.Transaction(func(tx *gorm.DB) error {
+ // 清理旧的多语言翻译和可见性规则,避免级联更新产生废弃记录
+ if err := tx.Where("item_id = ?", item.ID).Delete(&model.NavigationItemTranslation{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("item_id = ?", item.ID).Delete(&model.NavigationVisibilityRule{}).Error; err != nil {
+ return err
+ }
+
+ // 更新字段
+ item.ParentID = input.ParentID
+ item.Type = input.Type
+ item.ModuleKey = input.ModuleKey
+ item.Path = input.Path
+ item.URL = input.URL
+ item.IconKey = input.IconKey
+ item.SortOrder = input.SortOrder
+ item.Enabled = input.Enabled
+ item.OpenInNewTab = input.OpenInNewTab
+ item.ExactActive = input.ExactActive
+
+ // 保存主体,忽略关联表的自动保存,避免与接下来的手动保存发生冲突
+ if err := tx.Omit("Translations", "Rules").Save(&item).Error; err != nil {
+ return err
+ }
+
+ // 创建新的 Translations
+ for i := range input.Translations {
+ input.Translations[i].ItemID = item.ID
+ input.Translations[i].ID = 0 // 重置 ID 确保插入
+ if err := tx.Create(&input.Translations[i]).Error; err != nil {
+ return err
+ }
+ }
+
+ // 创建新的 Rules
+ for i := range input.Rules {
+ input.Rules[i].ItemID = item.ID
+ input.Rules[i].ID = 0 // 重置 ID 确保插入
+ if err := tx.Create(&input.Rules[i]).Error; err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
+}
+
+// AdminDeleteItem 删除节点(级联物理删除关联的 Translations 和 Rules)
+func AdminDeleteItem(c *gin.Context) {
+ idStr := c.Param("id")
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid item id"})
+ return
+ }
+
+ var item model.NavigationItem
+ if err := model.DB.First(&item, id).Error; err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": "item not found"})
+ return
+ }
+
+ err = model.DB.Transaction(func(tx *gorm.DB) error {
+ // 如果有子节点,将其父节点引用置为空,使子节点不致变成废弃不可达孤儿节点(或者可以选择级联删除子项)
+ // 在这里,按严谨级联规则,我们将子节点的 parent_id 设为 nil
+ if err := tx.Model(&model.NavigationItem{}).Where("parent_id = ?", item.ID).Update("parent_id", nil).Error; err != nil {
+ return err
+ }
+
+ // 删除主体,触发外键约束自动级联删除 translations 和 rules
+ return tx.Delete(&item).Error
+ })
+
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "item deleted successfully"})
+}
+
+type ReorderInput struct {
+ ItemID uint `json:"item_id"`
+ SortOrder int `json:"sort_order"`
+}
+
+// AdminReorderItems 批量节点重新排序接口
+func AdminReorderItems(c *gin.Context) {
+ var inputs []ReorderInput
+ if err := c.ShouldBindJSON(&inputs); err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ err := model.DB.Transaction(func(tx *gorm.DB) error {
+ for _, input := range inputs {
+ if err := tx.Model(&model.NavigationItem{}).Where("id = ?", input.ItemID).Update("sort_order", input.SortOrder).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+
+ service.NavService.InvalidateCache()
+ c.JSON(http.StatusOK, gin.H{"success": true, "message": "items reordered successfully"})
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index be8c885b186a..b9dd120d4f5b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,7 +21,7 @@ services:
restart: always
command: --log-dir /app/logs
ports:
- - "3000:3000"
+ - "3001:3000"
volumes:
- ./data:/data
- ./logs:/app/logs
diff --git a/middleware/auth.go b/middleware/auth.go
index 23d933fbe0c1..72eec75bd8b3 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -162,6 +162,16 @@ func TryUserAuth() func(c *gin.Context) {
id := session.Get("id")
if id != nil {
c.Set("id", id)
+ if role := session.Get("role"); role != nil {
+ c.Set("role", role)
+ }
+ if username := session.Get("username"); username != nil {
+ c.Set("username", username)
+ }
+ if group := session.Get("group"); group != nil {
+ c.Set("group", group)
+ c.Set("user_group", group)
+ }
}
c.Next()
}
diff --git a/model/main.go b/model/main.go
index 9083ee57ab90..bcd207841892 100644
--- a/model/main.go
+++ b/model/main.go
@@ -281,10 +281,15 @@ func migrateDB() error {
&CustomOAuthProvider{},
&UserOAuthBinding{},
&PerfMetric{},
+ &NavigationMenu{},
+ &NavigationItem{},
+ &NavigationItemTranslation{},
+ &NavigationVisibilityRule{},
)
if err != nil {
return err
}
+ go seedDefaultNavigation()
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -330,6 +335,10 @@ func migrateDBFast() error {
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&PerfMetric{}, "PerfMetric"},
+ {&NavigationMenu{}, "NavigationMenu"},
+ {&NavigationItem{}, "NavigationItem"},
+ {&NavigationItemTranslation{}, "NavigationItemTranslation"},
+ {&NavigationVisibilityRule{}, "NavigationVisibilityRule"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
@@ -706,3 +715,137 @@ func PingDB() error {
common.SysLog("Database pinged successfully")
return nil
}
+
+func seedDefaultNavigation() {
+ var count int64
+ err := DB.Model(&NavigationMenu{}).Where("key = ?", "default_web_top").Count(&count).Error
+ if err != nil {
+ common.SysError("failed to query default_web_top menu: " + err.Error())
+ return
+ }
+ if count > 0 {
+ return // 已经初始化过了,无需重复初始化
+ }
+
+ common.SysLog("Initializing default top navigation menu database records...")
+
+ // 1. 创建默认顶部导航菜单
+ menu := NavigationMenu{
+ Key: "default_web_top",
+ Name: "默认顶部导航栏",
+ Client: "web_default",
+ Surface: "top",
+ Enabled: true,
+ IsSystem: true,
+ }
+ // 2. 初始内置模块定义
+ type itemDef struct {
+ ModuleKey string
+ SortOrder int
+ IconKey string
+ Locales map[string]string
+ }
+
+ defaultItems := []itemDef{
+ {
+ ModuleKey: "home",
+ SortOrder: 1,
+ IconKey: "home",
+ Locales: map[string]string{
+ "en": "Home",
+ "zh-CN": "首页",
+ "zh-TW": "首頁",
+ },
+ },
+ {
+ ModuleKey: "console",
+ SortOrder: 2,
+ IconKey: "layout-dashboard",
+ Locales: map[string]string{
+ "en": "Console",
+ "zh-CN": "控制台",
+ "zh-TW": "控制台",
+ },
+ },
+ {
+ ModuleKey: "pricing",
+ SortOrder: 3,
+ IconKey: "credit-card",
+ Locales: map[string]string{
+ "en": "Model Square",
+ "zh-CN": "模型广场",
+ "zh-TW": "模型廣場",
+ },
+ },
+ {
+ ModuleKey: "rankings",
+ SortOrder: 4,
+ IconKey: "trophy",
+ Locales: map[string]string{
+ "en": "Rankings",
+ "zh-CN": "排行榜",
+ "zh-TW": "排行榜",
+ },
+ },
+ {
+ ModuleKey: "docs",
+ SortOrder: 5,
+ IconKey: "book-open",
+ Locales: map[string]string{
+ "en": "Docs",
+ "zh-CN": "文档",
+ "zh-TW": "文檔",
+ },
+ },
+ {
+ ModuleKey: "about",
+ SortOrder: 6,
+ IconKey: "info",
+ Locales: map[string]string{
+ "en": "About",
+ "zh-CN": "关于",
+ "zh-TW": "关于",
+ },
+ },
+ }
+
+ // 开启事务进行菜单和菜单项的原子化创建
+ err = DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&menu).Error; err != nil {
+ return err
+ }
+
+ for _, def := range defaultItems {
+ item := NavigationItem{
+ MenuID: menu.ID,
+ Type: "builtin_module",
+ ModuleKey: def.ModuleKey,
+ IconKey: def.IconKey,
+ SortOrder: def.SortOrder,
+ Enabled: true,
+ }
+ if err := tx.Create(&item).Error; err != nil {
+ return err
+ }
+
+ // 插入多语言翻译
+ for locale, label := range def.Locales {
+ trans := NavigationItemTranslation{
+ ItemID: item.ID,
+ Locale: locale,
+ Label: label,
+ }
+ if err := tx.Create(&trans).Error; err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+ })
+
+ if err != nil {
+ common.SysError("failed to seed default navigation items: " + err.Error())
+ } else {
+ common.SysLog("Default top navigation menu initialized successfully")
+ }
+}
diff --git a/model/navigation.go b/model/navigation.go
new file mode 100644
index 000000000000..26eda28359c1
--- /dev/null
+++ b/model/navigation.go
@@ -0,0 +1,77 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+package model
+
+// NavigationMenu 定义导航菜单的集合类型(如顶部导航栏、侧边栏等)
+type NavigationMenu struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ Key string `json:"key" gorm:"type:varchar(64);uniqueIndex;not null"` // 例如 "default_web_top"
+ Name string `json:"name" gorm:"type:varchar(128);not null"` // 菜单名称
+ Client string `json:"client" gorm:"type:varchar(64);not null"` // "web_default", "mobile" 等
+ Surface string `json:"surface" gorm:"type:varchar(64);not null"` // "top", "sidebar", "footer" 等
+ Enabled bool `json:"enabled" gorm:"not null;default:true"`
+ IsSystem bool `json:"is_system" gorm:"not null;default:false"` // 系统置顶菜单,禁止删除
+ CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"`
+ UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"`
+}
+
+// NavigationItem 树状嵌套的菜单节点
+type NavigationItem struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ MenuID uint `json:"menu_id" gorm:"index;not null"`
+ ParentID *uint `json:"parent_id" gorm:"index"` // 父节点ID,允许为 nil 表示顶级节点
+ Type string `json:"type" gorm:"type:varchar(64);not null"` // builtin_module, internal_path, external_url, group, divider
+ ModuleKey string `json:"module_key" gorm:"type:varchar(128)"` // 内置模块对应的 Key(例如 "pricing")
+ Path string `json:"path" gorm:"type:varchar(255)"` // 站内路径
+ URL string `json:"url" gorm:"type:text"` // 外部链接
+ IconKey string `json:"icon_key" gorm:"type:varchar(128)"` // Lucide/LobeHub 的图标对应键
+ SortOrder int `json:"sort_order" gorm:"not null;default:0"` // 排序权重
+ Enabled bool `json:"enabled" gorm:"not null;default:true"`
+ OpenInNewTab bool `json:"open_in_new_tab" gorm:"not null;default:false"` // 是否在新标签页打开
+ ExactActive bool `json:"exact_active" gorm:"not null;default:false"` // 路由匹配时是否精确匹配
+ CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"`
+ UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"`
+
+ // 关联字段,不写入数据库,由 GORM 自动处理级联操作
+ Children []NavigationItem `json:"children,omitempty" gorm:"foreignKey:ParentID"`
+ Translations []NavigationItemTranslation `json:"translations,omitempty" gorm:"foreignKey:ItemID;constraint:OnDelete:CASCADE"`
+ Rules []NavigationVisibilityRule `json:"rules,omitempty" gorm:"foreignKey:ItemID;constraint:OnDelete:CASCADE"`
+}
+
+// NavigationItemTranslation 支持导航节点多语言翻译的数据表
+type NavigationItemTranslation struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ ItemID uint `json:"item_id" gorm:"uniqueIndex:idx_item_locale;not null"`
+ Locale string `json:"locale" gorm:"type:varchar(32);uniqueIndex:idx_item_locale;not null"` // 区域标识,如 "zh-CN", "en-US", "zh-TW"
+ Label string `json:"label" gorm:"type:varchar(255);not null"` // 显示给用户的文字
+ CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"`
+ UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"`
+}
+
+// NavigationVisibilityRule 控制导航节点精细可见性(如登录状态、角色等)的权限规则表
+type NavigationVisibilityRule struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ ItemID uint `json:"item_id" gorm:"index;not null"`
+ Effect string `json:"effect" gorm:"type:varchar(32);not null;default:'allow'"` // 作用效力:"allow" 或 "deny"
+ SubjectType string `json:"subject_type" gorm:"type:varchar(64);not null"` // 主体类型:everyone, anonymous, authenticated, role, user_group
+ SubjectValue string `json:"subject_value" gorm:"type:varchar(255);not null"` // 主体对应的值(例如:role 时对应 "admin", "root")
+ CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"`
+ UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"`
+}
diff --git a/router/api-router.go b/router/api-router.go
index 381d2ccd0fbf..32b7d469124e 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -53,6 +53,25 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth)
apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig)
+ // 动态菜单树接口
+ apiRouter.GET("/navigation/tree", middleware.TryUserAuth(), controller.GetNavigationTree)
+
+ // 菜单管理后台路由(必须管理员及以上权限)
+ navigationAdminRoute := apiRouter.Group("/navigation/admin")
+ navigationAdminRoute.Use(middleware.AdminAuth())
+ {
+ navigationAdminRoute.GET("/menus", controller.AdminGetMenus)
+ navigationAdminRoute.POST("/menus", controller.AdminCreateMenu)
+ navigationAdminRoute.PUT("/menus/:id", controller.AdminUpdateMenu)
+ navigationAdminRoute.DELETE("/menus/:id", controller.AdminDeleteMenu)
+
+ navigationAdminRoute.GET("/items", controller.AdminGetItems)
+ navigationAdminRoute.POST("/items", controller.AdminCreateItem)
+ navigationAdminRoute.PUT("/items/:id", controller.AdminUpdateItem)
+ navigationAdminRoute.DELETE("/items/:id", controller.AdminDeleteItem)
+ navigationAdminRoute.POST("/items/reorder", controller.AdminReorderItems)
+ }
+
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
diff --git a/service/navigation.go b/service/navigation.go
new file mode 100644
index 000000000000..43090a94448a
--- /dev/null
+++ b/service/navigation.go
@@ -0,0 +1,342 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+package service
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "gorm.io/gorm"
+)
+
+// NavigationItemDTO 下发给前端的统一菜单节点格式
+type NavigationItemDTO struct {
+ ID uint `json:"id"`
+ Type string `json:"type"`
+ ModuleKey string `json:"module_key,omitempty"`
+ Label string `json:"label"`
+ Path string `json:"path,omitempty"`
+ URL string `json:"url,omitempty"`
+ IconKey string `json:"icon_key,omitempty"`
+ OpenInNewTab bool `json:"open_in_new_tab"`
+ ExactActive bool `json:"exact_active"`
+ Children []NavigationItemDTO `json:"children,omitempty"`
+}
+
+type NavigationService struct {
+ cache map[string][]NavigationItemDTO
+ cacheMu sync.RWMutex
+}
+
+var NavService = &NavigationService{
+ cache: make(map[string][]NavigationItemDTO),
+}
+
+// GetVisibleNavigationTree 获取指定菜单可见的过滤树(线程安全,基于内存缓存)
+func (s *NavigationService) GetVisibleNavigationTree(menuKey string, locale string, userRole int, userGroup string, isAuthenticated bool) ([]NavigationItemDTO, error) {
+ cacheKey := s.buildCacheKey(menuKey, locale, userRole, userGroup, isAuthenticated)
+
+ // 1. 读缓存
+ s.cacheMu.RLock()
+ if cachedData, ok := s.cache[cacheKey]; ok {
+ s.cacheMu.RUnlock()
+ return cachedData, nil
+ }
+ s.cacheMu.RUnlock()
+
+ // 2. 查数据库并拼装
+ var menu model.NavigationMenu
+ err := model.DB.Where("key = ? AND enabled = ?", menuKey, true).First(&menu).Error
+ if err != nil {
+ return nil, fmt.Errorf("menu not found: %w", err)
+ }
+
+ var items []model.NavigationItem
+ err = model.DB.Where("menu_id = ? AND enabled = ?", menu.ID, true).
+ Order("sort_order asc, id asc").
+ Preload("Translations").
+ Preload("Rules").
+ Find(&items).Error
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch menu items: %w", err)
+ }
+
+ // 3. 过滤并翻译
+ visibleItems := make([]model.NavigationItem, 0, len(items))
+ for _, item := range items {
+ if s.checkVisibility(item.Rules, userRole, userGroup, isAuthenticated) {
+ visibleItems = append(visibleItems, item)
+ }
+ }
+
+ // 4. 构建树形结构
+ tree := s.buildTree(visibleItems, locale)
+
+ // 5. 写入缓存
+ s.cacheMu.Lock()
+ s.cache[cacheKey] = tree
+ s.cacheMu.Unlock()
+
+ return tree, nil
+}
+
+// InvalidateCache 清空所有缓存(在管理端 CRUD 修改导航后调用)
+func (s *NavigationService) InvalidateCache() {
+ s.cacheMu.Lock()
+ s.cache = make(map[string][]NavigationItemDTO)
+ s.cacheMu.Unlock()
+ common.SysLog("Navigation memory cache invalidated")
+}
+
+// buildCacheKey 构造唯一的缓存 Key
+func (s *NavigationService) buildCacheKey(menuKey, locale string, userRole int, userGroup string, isAuthenticated bool) string {
+ return fmt.Sprintf("%s:%s:%d:%s:%t", menuKey, locale, userRole, userGroup, isAuthenticated)
+}
+
+// checkVisibility 验证节点权限,实施 RBAC 可见性过滤规则
+func (s *NavigationService) checkVisibility(rules []model.NavigationVisibilityRule, userRole int, userGroup string, isAuthenticated bool) bool {
+ if len(rules) == 0 {
+ return true // 无规则限制,默认所有人可见
+ }
+
+ hasAllowRules := false
+ allowMatched := false
+
+ for _, rule := range rules {
+ matched := s.evaluateRuleSubject(rule.SubjectType, rule.SubjectValue, userRole, userGroup, isAuthenticated)
+
+ if rule.Effect == "deny" {
+ if matched {
+ return false // 只要命中任何一条 deny 规则,立即不可见
+ }
+ } else if rule.Effect == "allow" {
+ hasAllowRules = true
+ if matched {
+ allowMatched = true
+ }
+ }
+ }
+
+ // 如果配置了 allow 规则,必须命中至少一条 allow 规则才可见
+ if hasAllowRules {
+ return allowMatched
+ }
+
+ return true
+}
+
+// evaluateRuleSubject 判断用户是否符合规则主体
+func (s *NavigationService) evaluateRuleSubject(subjectType, subjectValue string, userRole int, userGroup string, isAuthenticated bool) bool {
+ switch subjectType {
+ case "everyone":
+ return true
+ case "anonymous":
+ return !isAuthenticated
+ case "authenticated":
+ return isAuthenticated
+ case "role":
+ if !isAuthenticated {
+ return false
+ }
+ // 角色判断规范:
+ // "root" (100) -> 仅 root 匹配
+ // "admin" (10) -> admin (10) 和 root (100) 匹配
+ // "user" (1) -> 所有登录用户匹配
+ switch strings.ToLower(subjectValue) {
+ case "root":
+ return userRole == common.RoleRootUser
+ case "admin":
+ return userRole >= common.RoleAdminUser
+ case "user":
+ return userRole >= common.RoleCommonUser
+ default:
+ return false
+ }
+ case "user_group":
+ if !isAuthenticated {
+ return false
+ }
+ return userGroup == subjectValue
+ default:
+ return false
+ }
+}
+
+// buildTree 一次性遍历将扁平列表组装为树形结构,并应用翻译 fallback 规则
+func (s *NavigationService) buildTree(items []model.NavigationItem, locale string) []NavigationItemDTO {
+ // 初始化节点映射表
+ dtoMap := make(map[uint]*NavigationItemDTO)
+ for _, item := range items {
+ dto := &NavigationItemDTO{
+ ID: item.ID,
+ Type: item.Type,
+ ModuleKey: item.ModuleKey,
+ Path: item.Path,
+ URL: item.URL,
+ IconKey: item.IconKey,
+ OpenInNewTab: item.OpenInNewTab,
+ ExactActive: item.ExactActive,
+ Label: s.translateLabel(item.Translations, item.ModuleKey, locale),
+ Children: []NavigationItemDTO{},
+ }
+ dtoMap[item.ID] = dto
+ }
+
+ var rootDTOs []NavigationItemDTO
+
+ // 二次遍历组装树状父子层级
+ for _, item := range items {
+ dto := dtoMap[item.ID]
+ if dto == nil {
+ continue
+ }
+
+ if item.ParentID == nil {
+ // 顶级菜单
+ rootDTOs = append(rootDTOs, *dto)
+ } else {
+ // 子菜单,挂载到父节点下
+ parentDTO := dtoMap[*item.ParentID]
+ if parentDTO != nil {
+ parentDTO.Children = append(parentDTO.Children, *dto)
+ } else {
+ // 父节点已在权限过滤中被裁剪或被禁用,降级作为顶级项(这里按严谨重构规范:无父节点的子项如果无有效父节点,不显示)
+ // 或者可以选择放入 rootDTOs。在此设计中,如果父节点被权限过滤掉,其子节点在软件工程规范中应该同步不可见
+ }
+ }
+ }
+
+ // 重新深拷贝或扁平复制以消除多级嵌套中由于引用的子对象在 map 树组装时的错乱
+ var result []NavigationItemDTO
+ for _, rootItem := range rootDTOs {
+ result = append(result, s.deepCopyDTO(rootItem, dtoMap))
+ }
+
+ return result
+}
+
+// deepCopyDTO 保证树的深拷贝以维持嵌套结构的正确格式
+func (s *NavigationService) deepCopyDTO(node NavigationItemDTO, dtoMap map[uint]*NavigationItemDTO) NavigationItemDTO {
+ actualNode := dtoMap[node.ID]
+ if actualNode == nil {
+ return node
+ }
+
+ var copiedChildren []NavigationItemDTO
+ for _, child := range actualNode.Children {
+ copiedChildren = append(copiedChildren, s.deepCopyDTO(child, dtoMap))
+ }
+
+ node.Children = copiedChildren
+ return node
+}
+
+// translateLabel 多语言 Fallback 精准解析
+func (s *NavigationService) translateLabel(translations []model.NavigationItemTranslation, moduleKey string, targetLocale string) string {
+ if len(translations) == 0 {
+ return moduleKey // 极端无翻译记录下的兜底,展示内置模块键名
+ }
+
+ transMap := make(map[string]string)
+ for _, t := range translations {
+ transMap[strings.ToLower(t.Locale)] = t.Label
+ }
+
+ target := strings.ToLower(targetLocale)
+
+ // 1. 精确匹配(如 zh-cn)
+ if label, ok := transMap[target]; ok {
+ return label
+ }
+
+ // 2. 去除区域后缀的模糊匹配(如 zh-tw -> zh)
+ if parts := strings.Split(target, "-"); len(parts) > 1 {
+ if label, ok := transMap[parts[0]]; ok {
+ return label
+ }
+ }
+
+ // 2.5 基础语言前缀模糊匹配(如 target="zh",则匹配 "zh-cn" 或 "zh-tw")
+ for k, v := range transMap {
+ if strings.HasPrefix(k, target+"-") {
+ return v
+ }
+ }
+
+ // 3. Fallback 到英语 "en"
+ if label, ok := transMap["en"]; ok {
+ return label
+ }
+ if label, ok := transMap["en-us"]; ok {
+ return label
+ }
+
+ // 4. Fallback 到中文 "zh-cn"
+ if label, ok := transMap["zh-cn"]; ok {
+ return label
+ }
+ if label, ok := transMap["zh"]; ok {
+ return label
+ }
+
+ // 5. Fallback 到第一条已有翻译
+ return translations[0].Label
+}
+
+// SaveMenuWithTransaction 用于管理端安全保存(包含子节点和翻译等的事务性级联保存)
+func (s *NavigationService) SaveMenuWithTransaction(menu *model.NavigationMenu) error {
+ // 可在此实现需要强事务绑定的复杂业务逻辑
+ return model.DB.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Save(menu).Error; err != nil {
+ return err
+ }
+ s.InvalidateCache()
+ return nil
+ })
+}
+
+// ValidateItemURL 拦截恶意 URL 并防范 XSS 漏洞
+func (s *NavigationService) ValidateItemURL(itemType string, itemURL string) error {
+ if itemType != "external_url" {
+ return nil
+ }
+
+ trimmedURL := strings.TrimSpace(itemURL)
+ if trimmedURL == "" {
+ return errors.New("external URL cannot be empty")
+ }
+
+ lowerURL := strings.ToLower(trimmedURL)
+ // 拦截包含 javascript: 等具有运行脚本能力的恶意协议
+ if strings.HasPrefix(lowerURL, "javascript:") || strings.HasPrefix(lowerURL, "data:") {
+ return errors.New("malicious URL protocol detected")
+ }
+
+ // 必须以 http:// 或 https:// 开头
+ if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
+ return errors.New("external URL must start with http:// or https://")
+ }
+
+ return nil
+}
diff --git a/service/navigation_test.go b/service/navigation_test.go
new file mode 100644
index 000000000000..76c19f9a600b
--- /dev/null
+++ b/service/navigation_test.go
@@ -0,0 +1,242 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+
+package service
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupNavigationTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+
+ oldDB := model.DB
+ oldLogDB := model.LOG_DB
+
+ common.UsingSQLite = true
+ common.UsingMySQL = false
+ common.UsingPostgreSQL = false
+ common.RedisEnabled = false
+
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ require.NoError(t, err)
+
+ model.DB = db
+ model.LOG_DB = db
+
+ require.NoError(t, db.AutoMigrate(
+ &model.NavigationMenu{},
+ &model.NavigationItem{},
+ &model.NavigationItemTranslation{},
+ &model.NavigationVisibilityRule{},
+ ))
+
+ t.Cleanup(func() {
+ sqlDB, err := db.DB()
+ if err == nil {
+ _ = sqlDB.Close()
+ }
+ model.DB = oldDB
+ model.LOG_DB = oldLogDB
+ })
+
+ return db
+}
+
+func TestValidateItemURL(t *testing.T) {
+ tests := []struct {
+ name string
+ itemType string
+ url string
+ expectErr bool
+ }{
+ {"Valid HTTPS URL", "external_url", "https://google.com/path?query=1", false},
+ {"Valid HTTP URL", "external_url", "http://localhost:8080", false},
+ {"Empty URL", "external_url", "", true},
+ {"Malicious Javascript URL", "external_url", "javascript:alert(1)", true},
+ {"Malicious Data URL", "external_url", "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", true},
+ {"No Protocol URL", "external_url", "www.google.com", true},
+ {"Non-external type skipped", "builtin_module", "javascript:alert(1)", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := NavService.ValidateItemURL(tt.itemType, tt.url)
+ if tt.expectErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestGetVisibleNavigationTree(t *testing.T) {
+ db := setupNavigationTestDB(t)
+
+ // Create test menu
+ menu := model.NavigationMenu{
+ Key: "test_menu",
+ Name: "Test Menu",
+ Client: "web_default",
+ Surface: "top",
+ Enabled: true,
+ IsSystem: false,
+ }
+ require.NoError(t, db.Create(&menu).Error)
+
+ // Create test items
+ // Item 1: Builtin module - Everyone
+ item1 := model.NavigationItem{
+ MenuID: menu.ID,
+ Type: "builtin_module",
+ ModuleKey: "home",
+ SortOrder: 1,
+ Enabled: true,
+ }
+ require.NoError(t, db.Create(&item1).Error)
+ require.NoError(t, db.Create(&model.NavigationItemTranslation{
+ ItemID: item1.ID,
+ Locale: "zh-CN",
+ Label: "首页",
+ }).Error)
+ require.NoError(t, db.Create(&model.NavigationItemTranslation{
+ ItemID: item1.ID,
+ Locale: "en",
+ Label: "Home",
+ }).Error)
+
+ // Item 2: Admin only
+ item2 := model.NavigationItem{
+ MenuID: menu.ID,
+ Type: "internal_path",
+ Path: "/admin/users",
+ SortOrder: 2,
+ Enabled: true,
+ }
+ require.NoError(t, db.Create(&item2).Error)
+ require.NoError(t, db.Create(&model.NavigationItemTranslation{
+ ItemID: item2.ID,
+ Locale: "zh-CN",
+ Label: "用户管理",
+ }).Error)
+ require.NoError(t, db.Create(&model.NavigationVisibilityRule{
+ ItemID: item2.ID,
+ Effect: "allow",
+ SubjectType: "role",
+ SubjectValue: "admin",
+ }).Error)
+
+ // Item 3: VIP Group only
+ item3 := model.NavigationItem{
+ MenuID: menu.ID,
+ Type: "external_url",
+ URL: "https://vip.example.com",
+ SortOrder: 3,
+ Enabled: true,
+ }
+ require.NoError(t, db.Create(&item3).Error)
+ require.NoError(t, db.Create(&model.NavigationItemTranslation{
+ ItemID: item3.ID,
+ Locale: "zh-CN",
+ Label: "VIP专属",
+ }).Error)
+ require.NoError(t, db.Create(&model.NavigationVisibilityRule{
+ ItemID: item3.ID,
+ Effect: "allow",
+ SubjectType: "user_group",
+ SubjectValue: "VIP",
+ }).Error)
+
+ // Invalidate service cache to ensure fresh DB query
+ NavService.InvalidateCache()
+
+ // Case 1: Anonymous user
+ t.Run("Anonymous visibility", func(t *testing.T) {
+ NavService.InvalidateCache()
+ tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", 0, "", false)
+ require.NoError(t, err)
+ require.Len(t, tree, 1)
+ require.Equal(t, "首页", tree[0].Label)
+ })
+
+ // Case 2: Ordinary user (not admin, not VIP)
+ t.Run("Ordinary user visibility", func(t *testing.T) {
+ NavService.InvalidateCache()
+ tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleCommonUser, "default", true)
+ require.NoError(t, err)
+ require.Len(t, tree, 1)
+ require.Equal(t, "首页", tree[0].Label)
+ })
+
+ // Case 3: Admin user (role admin)
+ t.Run("Admin user visibility", func(t *testing.T) {
+ NavService.InvalidateCache()
+ tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleAdminUser, "default", true)
+ require.NoError(t, err)
+ require.Len(t, tree, 2)
+ require.Equal(t, "首页", tree[0].Label)
+ require.Equal(t, "用户管理", tree[1].Label)
+ })
+
+ // Case 4: VIP Group user (ordinary role)
+ t.Run("VIP group visibility", func(t *testing.T) {
+ NavService.InvalidateCache()
+ tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleCommonUser, "VIP", true)
+ require.NoError(t, err)
+ require.Len(t, tree, 2)
+ require.Equal(t, "首页", tree[0].Label)
+ require.Equal(t, "VIP专属", tree[1].Label)
+ })
+}
+
+func TestTranslateLabelFallback(t *testing.T) {
+ translations := []model.NavigationItemTranslation{
+ {Locale: "zh-CN", Label: "中文简体"},
+ {Locale: "zh-TW", Label: "中文繁體"},
+ {Locale: "en", Label: "English"},
+ }
+
+ tests := []struct {
+ locale string
+ expected string
+ }{
+ {"zh-CN", "中文简体"},
+ {"zh-tw", "中文繁體"},
+ {"zh-HK", "English"},
+ {"en-US", "English"},
+ {"fr-FR", "English"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.locale, func(t *testing.T) {
+ label := NavService.translateLabel(translations, "fallback_module_key", tt.locale)
+ require.Equal(t, tt.expected, label)
+ })
+ }
+}
diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go
index 41c91a15ae23..fe285315727a 100644
--- a/service/waffo_pancake_test.go
+++ b/service/waffo_pancake_test.go
@@ -17,6 +17,9 @@ import (
func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
t.Helper()
+ oldDB := model.DB
+ oldLogDB := model.LOG_DB
+
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
@@ -36,6 +39,8 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
if err == nil {
_ = sqlDB.Close()
}
+ model.DB = oldDB
+ model.LOG_DB = oldLogDB
})
return db
diff --git a/web/default/src/components/layout/components/public-navigation.tsx b/web/default/src/components/layout/components/public-navigation.tsx
index 4e8cb752fddb..a2ed39964e62 100644
--- a/web/default/src/components/layout/components/public-navigation.tsx
+++ b/web/default/src/components/layout/components/public-navigation.tsx
@@ -16,11 +16,22 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
+
import { Link } from '@tanstack/react-router'
+import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+} from '@/components/ui/dropdown-menu'
import { defaultTopNavLinks } from '../config/top-nav.config'
-import type { TopNavLink } from '../types'
+import { type TopNavLink } from '../types'
interface PublicNavigationProps {
/**
@@ -42,45 +53,111 @@ export function PublicNavigation({
links: providedLinks,
className,
}: PublicNavigationProps = {}) {
- // Use the same logic as AppHeader: prioritize dynamic links from backend
const dynamicLinks = useTopNavLinks()
const defaultLinks = providedLinks || defaultTopNavLinks
const links = dynamicLinks.length > 0 ? dynamicLinks : defaultLinks
- return (
-
>
)
diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts
index 087ff2e54090..ff24f07c4384 100644
--- a/web/default/src/components/layout/types.ts
+++ b/web/default/src/components/layout/types.ts
@@ -91,6 +91,8 @@ export type TopNavLink = {
disabled?: boolean
requiresAuth?: boolean
external?: boolean
+ openInNewTab?: boolean
+ children?: TopNavLink[]
}
/**
diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx
index 7a4bd04ce587..5da80a4f19bd 100644
--- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx
+++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx
@@ -16,284 +16,559 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import { useEffect, useMemo } from 'react'
-import * as z from 'zod'
-import { useForm } from 'react-hook-form'
-import { zodResolver } from '@hookform/resolvers/zod'
+
+import { useState, useMemo } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormLabel,
- FormMessage,
-} from '@/components/ui/form'
+ Plus,
+ Edit2,
+ Trash2,
+ ArrowUp,
+ ArrowDown,
+ Globe,
+ Lock,
+ ExternalLink,
+ FolderPlus,
+} from 'lucide-react'
+import { api } from '@/lib/api'
+import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
- SettingsControlChildren,
- SettingsForm,
- SettingsSwitchContent,
- SettingsControlGroup,
- SettingsSwitchItem,
-} from '../components/settings-form-layout'
-import { SettingsPageFormActions } from '../components/settings-page-context'
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
import { SettingsSection } from '../components/settings-section'
-import { useUpdateOption } from '../hooks/use-update-option'
-import {
- HEADER_NAV_DEFAULT,
- type HeaderNavModulesConfig,
- serializeHeaderNavModules,
-} from './config'
-
-const headerNavSchema = z.object({
- home: z.boolean(),
- console: z.boolean(),
- pricingEnabled: z.boolean(),
- pricingRequireAuth: z.boolean(),
- rankingsEnabled: z.boolean(),
- rankingsRequireAuth: z.boolean(),
- docs: z.boolean(),
- about: z.boolean(),
-})
-
-type HeaderNavFormValues = z.infer
-
-type HeaderNavigationSectionProps = {
- config: HeaderNavModulesConfig
- initialSerialized: string
+
+type NavigationItemTranslation = {
+ id?: number
+ locale: string
+ label: string
}
-const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
- home:
- config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home),
- console:
- config.console === undefined
- ? HEADER_NAV_DEFAULT.console
- : Boolean(config.console),
- pricingEnabled:
- config.pricing?.enabled === undefined
- ? HEADER_NAV_DEFAULT.pricing.enabled
- : Boolean(config.pricing.enabled),
- pricingRequireAuth:
- config.pricing?.requireAuth === undefined
- ? HEADER_NAV_DEFAULT.pricing.requireAuth
- : Boolean(config.pricing.requireAuth),
- rankingsEnabled:
- config.rankings?.enabled === undefined
- ? HEADER_NAV_DEFAULT.rankings.enabled
- : Boolean(config.rankings.enabled),
- rankingsRequireAuth:
- config.rankings?.requireAuth === undefined
- ? HEADER_NAV_DEFAULT.rankings.requireAuth
- : Boolean(config.rankings.requireAuth),
- docs:
- config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
- about:
- config.about === undefined
- ? HEADER_NAV_DEFAULT.about
- : Boolean(config.about),
-})
-
-export function HeaderNavigationSection({
- config,
- initialSerialized,
-}: HeaderNavigationSectionProps) {
- const { t } = useTranslation()
- const updateOption = useUpdateOption()
- const formDefaults = useMemo(() => toFormValues(config), [config])
+type NavigationVisibilityRule = {
+ id?: number
+ effect: 'allow' | 'deny'
+ subject_type: 'everyone' | 'anonymous' | 'authenticated' | 'role' | 'user_group'
+ subject_value: string
+}
- const form = useForm({
- resolver: zodResolver(headerNavSchema),
- defaultValues: formDefaults,
+type NavigationItem = {
+ id: number
+ menu_id: number
+ parent_id?: number
+ type: 'builtin_module' | 'internal_path' | 'external_url' | 'group' | 'divider'
+ module_key?: string
+ path?: string
+ url?: string
+ icon_key?: string
+ sort_order: number
+ enabled: boolean
+ open_in_new_tab: boolean
+ exact_active: boolean
+ translations: NavigationItemTranslation[]
+ rules: NavigationVisibilityRule[]
+}
+
+export function HeaderNavigationSection() {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+
+ // 编辑弹窗状态
+ const [editDialogOpen, setEditDialogOpen] = useState(false)
+ const [editingItem, setEditingItem] = useState | null>(null)
+
+ // 1. 获取菜单容器列表,定位顶级 web top 菜单
+ const { data: menus = [] } = useQuery({
+ queryKey: ['admin-navigation-menus'],
+ queryFn: async () => {
+ const res = await api.get('/api/navigation/admin/menus')
+ return res.data?.data || []
+ },
})
- useEffect(() => {
- form.reset(formDefaults)
- }, [formDefaults, form])
-
- const onSubmit = async (values: HeaderNavFormValues) => {
- const payload: HeaderNavModulesConfig = {
- ...config,
- home: values.home,
- console: values.console,
- docs: values.docs,
- about: values.about,
- pricing: {
- ...(config.pricing ?? HEADER_NAV_DEFAULT.pricing),
- enabled: values.pricingEnabled,
- requireAuth: values.pricingRequireAuth,
- },
- rankings: {
- ...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
- enabled: values.rankingsEnabled,
- requireAuth: values.rankingsRequireAuth,
- },
- }
+ // 派生出 activeMenuID,避免在异步 queryFn 中调用 setState 造成的缓存及 React 状态异步更新不一致问题
+ const activeMenuID = useMemo(() => {
+ const defaultMenu = menus.find((m: any) => m.key === 'default_web_top')
+ return defaultMenu ? defaultMenu.id : null
+ }, [menus])
- const serialized = serializeHeaderNavModules(payload)
- if (serialized === initialSerialized) {
- return
+ // 2. 获取该菜单下所有节点列表
+ const { data: flatItems = [], refetch: refetchItems } = useQuery({
+ queryKey: ['admin-navigation-items', activeMenuID],
+ queryFn: async () => {
+ if (!activeMenuID) return []
+ const res = await api.get('/api/navigation/admin/items', {
+ params: { menu_id: activeMenuID },
+ })
+ return res.data?.data || []
+ },
+ enabled: !!activeMenuID,
+ })
+
+ // 3. 构建多级缩进排序好的展示列表
+ const displayItems = useMemo(() => {
+ const list: Array<{ item: NavigationItem; depth: number }> = []
+
+ const recurse = (parentID: number | undefined, depth: number) => {
+ const children = flatItems.filter((it) => {
+ if (!parentID) return !it.parent_id
+ return it.parent_id === parentID
+ })
+
+ children.forEach((child) => {
+ list.push({ item: child, depth })
+ recurse(child.id, depth + 1)
+ })
}
- await updateOption.mutateAsync({
- key: 'HeaderNavModules',
- value: serialized,
- })
- }
+ recurse(undefined, 0)
+ return list
+ }, [flatItems])
- const resetToDefault = () => {
- form.reset(toFormValues(HEADER_NAV_DEFAULT))
- }
+ // ================= 级联 CRUD 修改的 Mutations =================
- const simpleModules: Array<{
- key: keyof HeaderNavFormValues
- title: string
- description: string
- }> = [
- {
- key: 'home',
- title: t('Home'),
- description: t('Landing page with system overview.'),
+ // 创建/更新节点
+ const saveMutation = useMutation({
+ mutationFn: async (item: Partial) => {
+ if (item.id) {
+ return api.put(`/api/navigation/admin/items/${item.id}`, item)
+ } else {
+ return api.post('/api/navigation/admin/items', item)
+ }
},
- {
- key: 'console',
- title: t('Console'),
- description: t('User dashboard and quota controls.'),
+ onSuccess: (res) => {
+ if (res.data?.success) {
+ toast.success(t('Navigation settings saved successfully'))
+ setEditDialogOpen(false)
+ refetchItems()
+ // 同步刷新用户侧导航栏缓存
+ queryClient.invalidateQueries({ queryKey: ['navigation-tree'] })
+ }
},
- {
- key: 'docs',
- title: t('Docs'),
- description: t('Documentation or external knowledge base.'),
+ })
+
+ // 删除节点
+ const deleteMutation = useMutation({
+ mutationFn: async (id: number) => {
+ return api.delete(`/api/navigation/admin/items/${id}`)
},
- {
- key: 'about',
- title: t('About'),
- description: t('Static page describing the platform.'),
+ onSuccess: (res) => {
+ if (res.data?.success) {
+ toast.success(t('Menu item deleted'))
+ refetchItems()
+ queryClient.invalidateQueries({ queryKey: ['navigation-tree'] })
+ }
},
- ]
-
- const accessModules: Array<{
- enabledKey: keyof HeaderNavFormValues
- requireAuthKey: keyof HeaderNavFormValues
- requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled'
- title: string
- description: string
- requireAuthTitle: string
- requireAuthDescription: string
- }> = [
- {
- enabledKey: 'pricingEnabled',
- requireAuthKey: 'pricingRequireAuth',
- requireAuthDependsOn: 'pricingEnabled',
- title: t('Model Square'),
- description: t('Public model catalog and pricing page.'),
- requireAuthTitle: t('Require login to view models'),
- requireAuthDescription: t(
- 'Visitors must authenticate before accessing the pricing directory.'
- ),
+ })
+
+ // 重新排序
+ const reorderMutation = useMutation({
+ mutationFn: async (reorderList: Array<{ item_id: number; sort_order: number }>) => {
+ return api.post('/api/navigation/admin/items/reorder', reorderList)
},
- {
- enabledKey: 'rankingsEnabled',
- requireAuthKey: 'rankingsRequireAuth',
- requireAuthDependsOn: 'rankingsEnabled',
- title: t('Rankings'),
- description: t('Public rankings page based on live usage data.'),
- requireAuthTitle: t('Require login to view rankings'),
- requireAuthDescription: t(
- 'Visitors must authenticate before accessing the rankings page.'
- ),
+ onSuccess: () => {
+ refetchItems()
+ queryClient.invalidateQueries({ queryKey: ['navigation-tree'] })
},
- ]
+ })
+
+ // ================= 辅助操作 =================
+
+ const handleOpenCreate = (parentID?: number) => {
+ setEditingItem({
+ menu_id: activeMenuID || 1,
+ parent_id: parentID,
+ type: 'builtin_module',
+ module_key: 'home',
+ enabled: true,
+ open_in_new_tab: false,
+ exact_active: false,
+ sort_order: flatItems.length + 1,
+ translations: [
+ { locale: 'zh-CN', label: '' },
+ { locale: 'en', label: '' },
+ { locale: 'zh-TW', label: '' },
+ ],
+ rules: [],
+ })
+ setEditDialogOpen(true)
+ }
+
+ const handleOpenEdit = (item: NavigationItem) => {
+ // 拷贝多语言配置,防修改污染
+ const translations = ['zh-CN', 'en', 'zh-TW'].map((locale) => {
+ const found = (item.translations || []).find((t) => t.locale === locale)
+ return { locale, label: found ? found.label : '' }
+ })
+
+ setEditingItem({
+ ...item,
+ translations,
+ })
+ setEditDialogOpen(true)
+ }
+
+ // 排序上移/下移
+ const handleMove = (index: number, direction: 'up' | 'down') => {
+ const siblingItems = displayItems.filter(
+ (it) => it.item.parent_id === displayItems[index].item.parent_id
+ )
+ const currentSiblingIdx = siblingItems.findIndex(
+ (it) => it.item.id === displayItems[index].item.id
+ )
+
+ let targetSiblingIdx = direction === 'up' ? currentSiblingIdx - 1 : currentSiblingIdx + 1
+ if (targetSiblingIdx < 0 || targetSiblingIdx >= siblingItems.length) return
+
+ const currentItem = siblingItems[currentSiblingIdx].item
+ const targetItem = siblingItems[targetSiblingIdx].item
+
+ // 互换权重并保存
+ reorderMutation.mutate([
+ { item_id: currentItem.id, sort_order: targetItem.sort_order },
+ { item_id: targetItem.id, sort_order: currentItem.sort_order },
+ ])
+ }
+
+ const handleSaveItem = () => {
+ if (!editingItem) return
+ const cnTrans = editingItem.translations?.find((t) => t.locale === 'zh-CN')
+ if (!cnTrans || !cnTrans.label.trim()) {
+ toast.error(t('Chinese label is required'))
+ return
+ }
+
+ saveMutation.mutate(editingItem)
+ }
+
+ const updateTranslation = (locale: string, val: string) => {
+ if (!editingItem || !editingItem.translations) return
+ const updated = editingItem.translations.map((t) => {
+ if (t.locale === locale) return { ...t, label: val }
+ return t
+ })
+ setEditingItem({ ...editingItem, translations: updated })
+ }
return (
-
)
}
diff --git a/web/default/src/features/system-settings/site/section-registry.tsx b/web/default/src/features/system-settings/site/section-registry.tsx
index 6cea57a672e0..4fd4874caf01 100644
--- a/web/default/src/features/system-settings/site/section-registry.tsx
+++ b/web/default/src/features/system-settings/site/section-registry.tsx
@@ -18,9 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { SystemInfoSection } from '../general/system-info-section'
import {
- parseHeaderNavModules,
parseSidebarModulesAdmin,
- serializeHeaderNavModules,
serializeSidebarModulesAdmin,
} from '../maintenance/config'
import { HeaderNavigationSection } from '../maintenance/header-navigation-section'
@@ -63,14 +61,9 @@ const SITE_SECTIONS = [
{
id: 'header-navigation',
titleKey: 'Header navigation',
- build: (settings: SiteSettings) => {
- const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
- const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
+ build: () => {
return (
-
+
)
},
},
diff --git a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
index 4a6eb0eb723b..8f37a3727971 100644
--- a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
+++ b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx
@@ -200,8 +200,8 @@ function CommonLogsCard({
{t('Time')}
.
For commercial licensing, please contact support@quantumnous.com
*/
-import { useMemo } from 'react'
+
+import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
+import { api } from '@/lib/api'
+import { BuiltinModulesRegistry } from '@/lib/nav-modules'
import { useAuthStore } from '@/stores/auth-store'
-import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules'
-import { useStatus } from '@/hooks/use-status'
-
-export type TopNavLink = {
- title: string
- href: string
- disabled?: boolean
- requiresAuth?: boolean
- external?: boolean
-}
+import { type TopNavLink } from '@/components/layout/types'
/**
- * Generate top navigation links based on HeaderNavModules configuration from backend /api/status
- * Backend format example (stringified JSON):
- * {
- * home: true,
- * console: true,
- * pricing: { enabled: true, requireAuth: false },
- * rankings: { enabled: true, requireAuth: false },
- * docs: true,
- * about: true
- * }
+ * 动态加载并拼装顶部导航树 Hook
*/
export function useTopNavLinks(): TopNavLink[] {
- const { t } = useTranslation()
- const { status } = useStatus()
+ const { i18n } = useTranslation()
const { auth } = useAuthStore()
- // Parse HeaderNavModules
- const modules = useMemo(() => {
- return parseHeaderNavModulesFromStatus(
- status as Record | null
- )
- }, [status])
-
- // Documentation link (may be external)
- const docsLink: string | undefined = status?.docs_link as string | undefined
-
- const isAuthed = !!auth?.user
-
- const links: TopNavLink[] = []
-
- // Home
- if (modules?.home !== false) {
- links.push({ title: t('Home'), href: '/' })
- }
-
- // Console -> /dashboard (new console path)
- if (modules?.console !== false) {
- links.push({ title: t('Console'), href: '/dashboard' })
- }
+ // 区分 i18n 语言环境
+ const currentLang = i18n.language || 'zh-CN'
+
+ // 利用 React Query 获取可见的菜单树
+ const { data: rawTree } = useQuery({
+ queryKey: ['navigation-tree', 'default_web_top', currentLang, auth?.user?.id],
+ queryFn: async () => {
+ const res = await api.get('/api/navigation/tree', {
+ params: {
+ menu_key: 'default_web_top',
+ lang: currentLang,
+ },
+ skipErrorHandler: true, // 避免加载失败弹窗影响全局交互,实施静默重试/加载
+ })
+ return res.data?.data || []
+ },
+ })
+
+ // 将后端动态返回的菜单节点转换为前端标准的顶级及多级嵌套路由格式
+ const links: TopNavLink[] = (rawTree || []).map(mapNavigationItemToLink)
- // Pricing
- const pricing = modules?.pricing
- if (pricing && typeof pricing === 'object' && pricing.enabled) {
- const requiresAuth = pricing.requireAuth && !isAuthed
- links.push({ title: t('Model Square'), href: '/pricing', requiresAuth })
- }
-
- // Rankings
- const rankings = modules?.rankings
- if (rankings && typeof rankings === 'object' && rankings.enabled) {
- const requiresAuth = rankings.requireAuth && !isAuthed
- links.push({ title: t('Rankings'), href: '/rankings', requiresAuth })
- }
+ return links
+}
- // Docs (supports external links)
- if (modules?.docs !== false) {
- if (docsLink) {
- links.push({ title: t('Docs'), href: docsLink, external: true })
- } else {
- links.push({ title: t('Docs'), href: '/docs' })
- }
+/**
+ * 映射后端 DTO 格式节点到前端导航项
+ */
+function mapNavigationItemToLink(item: any): TopNavLink {
+ let href = ''
+ let isExternal = false
+
+ switch (item.type) {
+ case 'builtin_module':
+ // 引用内置注册表的 SPA 路径
+ const meta = BuiltinModulesRegistry[item.module_key]
+ href = meta ? meta.to : '/'
+ break
+ case 'internal_path':
+ href = item.path || '/'
+ break
+ case 'external_url':
+ href = item.url || ''
+ isExternal = true
+ break
+ case 'group':
+ href = '#'
+ break
+ default:
+ href = '#'
}
- // About
- if (modules?.about !== false) {
- links.push({ title: t('About'), href: '/about' })
+ // 递归转换子菜单节点
+ const children =
+ item.children && item.children.length > 0
+ ? item.children.map(mapNavigationItemToLink)
+ : undefined
+
+ return {
+ title: item.label,
+ href,
+ external: isExternal,
+ openInNewTab: item.open_in_new_tab,
+ children,
}
-
- return links
}
diff --git a/web/default/src/lib/nav-modules.ts b/web/default/src/lib/nav-modules.ts
index 2e8611d2218c..c8cfb3897329 100644
--- a/web/default/src/lib/nav-modules.ts
+++ b/web/default/src/lib/nav-modules.ts
@@ -16,8 +16,31 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
+
import { getStatus } from '@/lib/api'
+// ================= 前端内置模块注册表 (Registry) =================
+
+export interface BuiltinModuleMeta {
+ moduleKey: string
+ defaultLabelKey: string // i18n 对应的多语言翻译键值
+ to: string // SPA 路由跳转路径
+ iconKey: string // 图标键名
+ activeMatch?: 'exact' | 'prefix' // 路由高亮匹配模式
+}
+
+// BuiltinModulesRegistry 声明了系统内所有支持注册为内置导航的页面元数据
+export const BuiltinModulesRegistry: Record = {
+ home: { moduleKey: 'home', defaultLabelKey: 'Home', to: '/', iconKey: 'home', activeMatch: 'exact' },
+ console: { moduleKey: 'console', defaultLabelKey: 'Console', to: '/dashboard', iconKey: 'layout-dashboard', activeMatch: 'prefix' },
+ pricing: { moduleKey: 'pricing', defaultLabelKey: 'Model Square', to: '/pricing', iconKey: 'credit-card', activeMatch: 'prefix' },
+ rankings: { moduleKey: 'rankings', defaultLabelKey: 'Rankings', to: '/rankings', iconKey: 'trophy', activeMatch: 'prefix' },
+ docs: { moduleKey: 'docs', defaultLabelKey: 'Docs', to: '/docs', iconKey: 'book-open', activeMatch: 'prefix' },
+ about: { moduleKey: 'about', defaultLabelKey: 'About', to: '/about', iconKey: 'info', activeMatch: 'prefix' }
+}
+
+// ================= 向下兼容的历史解析逻辑 =================
+
export type ModuleAccess = { enabled: boolean; requireAuth: boolean }
export type HeaderNavModule = 'rankings' | 'pricing'
From f2213df9b5fb524747b0445cce335be2c64c696b Mon Sep 17 00:00:00 2001
From: z23cc
Date: Sun, 31 May 2026 13:32:01 +0800
Subject: [PATCH 04/38] fix: reuse upstream model fetch for channel preview
---
controller/channel.go | 108 +++++++-----------------------------------
1 file changed, 16 insertions(+), 92 deletions(-)
diff --git a/controller/channel.go b/controller/channel.go
index c59e492a5a02..ed5d8185a424 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -14,7 +14,6 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
- "github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service"
@@ -993,9 +992,10 @@ func UpdateChannel(c *gin.Context) {
func FetchModels(c *gin.Context) {
var req struct {
- BaseURL string `json:"base_url"`
- Type int `json:"type"`
- Key string `json:"key"`
+ BaseURL string `json:"base_url"`
+ Type int `json:"type"`
+ Key string `json:"key"`
+ HeaderOverride string `json:"header_override"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1006,105 +1006,29 @@ func FetchModels(c *gin.Context) {
return
}
- baseURL := req.BaseURL
- if baseURL == "" {
- baseURL = constant.ChannelBaseURLs[req.Type]
+ // 预览/新建渠道时还没有入库的 Channel,复用已保存渠道的上游拉取逻辑,
+ // 避免各渠道在鉴权头、特殊模型地址、代理等细节上出现分叉。
+ channel := &model.Channel{
+ Type: req.Type,
+ Key: strings.TrimSpace(strings.Split(strings.TrimSpace(req.Key), "\n")[0]),
}
- // remove line breaks and extra spaces.
- key := strings.TrimSpace(req.Key)
- key = strings.Split(key, "\n")[0]
-
- if req.Type == constant.ChannelTypeOllama {
- models, err := ollama.FetchOllamaModels(baseURL, key)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
- })
- return
- }
-
- names := make([]string, 0, len(models))
- for _, modelInfo := range models {
- names = append(names, modelInfo.Name)
- }
-
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "data": names,
- })
- return
+ if baseURL := strings.TrimSpace(req.BaseURL); baseURL != "" {
+ channel.BaseURL = &baseURL
}
-
- if req.Type == constant.ChannelTypeGemini {
- models, err := gemini.FetchGeminiModels(baseURL, key, "")
- if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()),
- })
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "success": true,
- "data": models,
- })
- return
- }
-
- client := &http.Client{}
- url := fmt.Sprintf("%s/v1/models", baseURL)
-
- request, err := http.NewRequest("GET", url, nil)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{
- "success": false,
- "message": err.Error(),
- })
- return
+ if headerOverride := strings.TrimSpace(req.HeaderOverride); headerOverride != "" {
+ channel.HeaderOverride = &headerOverride
}
- request.Header.Set("Authorization", "Bearer "+key)
-
- response, err := client.Do(request)
+ models, err := fetchChannelUpstreamModelIDs(channel)
if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{
- "success": false,
- "message": err.Error(),
- })
- return
- }
- //check status code
- if response.StatusCode != http.StatusOK {
- c.JSON(http.StatusInternalServerError, gin.H{
- "success": false,
- "message": "Failed to fetch models",
- })
- return
- }
- defer response.Body.Close()
-
- var result struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
-
- if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{
+ c.JSON(http.StatusOK, gin.H{
"success": false,
- "message": err.Error(),
+ "message": fmt.Sprintf("获取模型列表失败: %s", err.Error()),
})
return
}
- var models []string
- for _, model := range result.Data {
- models = append(models, model.ID)
- }
-
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": models,
From fc75746d66622cdbf6122fe7edb807d0e5c2f2a8 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Sun, 31 May 2026 14:01:10 +0800
Subject: [PATCH 05/38] fix: add claude opus 4-8 defaults
---
relay/channel/claude/constants.go | 1 +
setting/ratio_setting/cache_ratio.go | 2 ++
setting/ratio_setting/model_ratio.go | 1 +
.../channels/components/drawers/channel-mutate-drawer.tsx | 5 +++++
4 files changed, 9 insertions(+)
diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go
index 3c516aefb7db..bbf6a07627ac 100644
--- a/relay/channel/claude/constants.go
+++ b/relay/channel/claude/constants.go
@@ -33,6 +33,7 @@ var ModelList = []string{
"claude-opus-4-7-medium",
"claude-opus-4-7-low",
"claude-opus-4-7-thinking",
+ "claude-opus-4-8",
}
var ChannelName = "claude"
diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go
index fe6e3b3262a4..18c760490af1 100644
--- a/setting/ratio_setting/cache_ratio.go
+++ b/setting/ratio_setting/cache_ratio.go
@@ -71,6 +71,7 @@ var defaultCacheRatio = map[string]float64{
"claude-opus-4-7-high": 0.1,
"claude-opus-4-7-medium": 0.1,
"claude-opus-4-7-low": 0.1,
+ "claude-opus-4-8": 0.1,
}
var defaultCreateCacheRatio = map[string]float64{
@@ -106,6 +107,7 @@ var defaultCreateCacheRatio = map[string]float64{
"claude-opus-4-7-high": 1.25,
"claude-opus-4-7-medium": 1.25,
"claude-opus-4-7-low": 1.25,
+ "claude-opus-4-8": 1.25,
}
//var defaultCreateCacheRatio = map[string]float64{}
diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go
index 80702ee42ad2..e2a2b200ad69 100644
--- a/setting/ratio_setting/model_ratio.go
+++ b/setting/ratio_setting/model_ratio.go
@@ -152,6 +152,7 @@ var defaultModelRatio = map[string]float64{
"claude-opus-4-7-high": 2.5,
"claude-opus-4-7-medium": 2.5,
"claude-opus-4-7-low": 2.5,
+ "claude-opus-4-8": 2.5,
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
"claude-opus-4-20250514": 7.5,
"claude-opus-4-1-20250805": 7.5,
diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
index 6b26cd171505..722439d35a41 100644
--- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
+++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx
@@ -415,6 +415,11 @@ export function ChannelMutateDrawer({
(model) => model.startsWith('gpt-') || model.startsWith('text-')
)
}
+ if (currentType === 14) {
+ return allModelsList.filter((model) =>
+ model.toLowerCase().startsWith('claude-')
+ )
+ }
return allModelsList
}, [allModelsList, currentType])
From f52a7b316f42374e14af11c8e6544cd2d4315ff7 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Sun, 31 May 2026 14:24:46 +0800
Subject: [PATCH 06/38] fix: align classic playground route
---
web/classic/src/App.jsx | 8 ++++++++
web/classic/src/components/layout/SiderBar.jsx | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx
index a5d1ebc00b32..fe8a6dda7917 100644
--- a/web/classic/src/App.jsx
+++ b/web/classic/src/App.jsx
@@ -155,6 +155,14 @@ function App() {
}
/>
+
+
+
+ }
+ />
{} }) => {
{
text: t('操练场'),
itemKey: 'playground',
- to: '/playground',
+ to: '/console/playground',
},
{
text: t('聊天'),
From 1359d601936ed77f88f684bf5f20a4b37597e0a3 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 12:29:08 +0800
Subject: [PATCH 07/38] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E5=AF=BC?=
=?UTF-8?q?=E5=85=A5Claude=E6=B8=A0=E9=81=93=20&=20=E9=BB=98=E8=AE=A4?=
=?UTF-8?q?=E6=B8=A0=E9=81=93=E7=B1=BB=E5=9E=8B=E6=94=B9=E4=B8=BAAnthropic?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增批量导入功能(classic/default双主题)
- 支持 余额密钥 格式,每行一条
- 自动命名:YYYYMMDDHHmm-余额-标签
- 自动使用Claude默认模型
- 逐条创建,实时显示进度和结果
- 创建渠道默认类型从OpenAI(1)改为Anthropic Claude(14)
- docker-local.sh 默认前端主题改为classic
---
bin/docker-local.sh | 2 +-
.../table/channels/ChannelsFilters.jsx | 15 +-
.../src/components/table/channels/index.jsx | 6 +
.../channels/modals/BatchImportModal.jsx | 480 ++++++++++++++++
.../channels/modals/EditChannelModal.jsx | 2 +-
.../src/hooks/channels/useChannelsData.jsx | 3 +
.../channels/components/channels-dialogs.tsx | 7 +
.../components/channels-primary-buttons.tsx | 11 +
.../channels/components/channels-provider.tsx | 1 +
.../dialogs/batch-import-dialog.tsx | 529 ++++++++++++++++++
.../src/features/channels/lib/channel-form.ts | 2 +-
11 files changed, 1054 insertions(+), 4 deletions(-)
create mode 100644 web/classic/src/components/table/channels/modals/BatchImportModal.jsx
create mode 100644 web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
index 313571643b8b..608409a21155 100755
--- a/bin/docker-local.sh
+++ b/bin/docker-local.sh
@@ -41,7 +41,7 @@ CRYPTO_SECRET="${CRYPTO_SECRET:-}"
NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}"
BUILD_ON_UP="${BUILD_ON_UP:-1}"
if [[ -z "${FRONTEND_THEME+x}" ]]; then
- FRONTEND_THEME="default"
+ FRONTEND_THEME="classic"
fi
usage() {
diff --git a/web/classic/src/components/table/channels/ChannelsFilters.jsx b/web/classic/src/components/table/channels/ChannelsFilters.jsx
index e97a1e3e37f0..483dfeb8caed 100644
--- a/web/classic/src/components/table/channels/ChannelsFilters.jsx
+++ b/web/classic/src/components/table/channels/ChannelsFilters.jsx
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Button, Form } from '@douyinfe/semi-ui';
-import { IconSearch } from '@douyinfe/semi-icons';
+import { IconSearch, IconUpload } from '@douyinfe/semi-icons';
const ChannelsFilters = ({
setEditingChannel,
@@ -34,6 +34,7 @@ const ChannelsFilters = ({
groupOptions,
loading,
searching,
+ setShowBatchImport,
t,
}) => {
return (
@@ -54,6 +55,18 @@ const ChannelsFilters = ({
{t('添加渠道')}
+ }
+ onClick={() => {
+ if (setShowBatchImport) setShowBatchImport(true);
+ }}
+ >
+ {t('批量导入')}
+
+
+ {/* Batch Import */}
+ setOpen('batch-import')}
+ size='sm'
+ >
+
+ {t('Batch Import')}
+
+
{/* More Actions */}
}>
diff --git a/web/default/src/features/channels/components/channels-provider.tsx b/web/default/src/features/channels/components/channels-provider.tsx
index 6fb80954bcd6..1184832d49a8 100644
--- a/web/default/src/features/channels/components/channels-provider.tsx
+++ b/web/default/src/features/channels/components/channels-provider.tsx
@@ -38,6 +38,7 @@ type DialogType =
| 'tag-batch-edit'
| 'edit-tag'
| 'copy-channel'
+ | 'batch-import'
| null
type UpstreamUpdateState = ReturnType
diff --git a/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx b/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
new file mode 100644
index 000000000000..344e9c863811
--- /dev/null
+++ b/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
@@ -0,0 +1,529 @@
+/*
+Copyright (C) 2023-2026 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact support@quantumnous.com
+*/
+import { useState, useMemo, useCallback } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ Loader2,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ FileUp,
+} from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { toast } from 'sonner'
+import { createChannel } from '../../api'
+import { channelsQueryKeys } from '../../lib'
+
+// ============================================================================
+// Types
+// ============================================================================
+
+interface ParsedEntry {
+ balance: number
+ key: string
+ name: string
+ lineNumber: number
+}
+
+interface ImportResult {
+ entry: ParsedEntry
+ success: boolean
+ error?: string
+}
+
+type ImportState = 'idle' | 'importing' | 'done'
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+const ANTHROPIC_CHANNEL_TYPE = 14
+const DEFAULT_MODELS =
+ 'claude-sonnet-4-20250514,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022'
+const DEFAULT_GROUP = 'default'
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function pad(n: number): string {
+ return n.toString().padStart(2, '0')
+}
+
+function generateTimestamp(): string {
+ const now = new Date()
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`
+}
+
+function generateChannelName(
+ balance: number,
+ suffix: string,
+ timestamp: string
+): string {
+ return `${timestamp}-${balance}-${suffix}`
+}
+
+function parseBatchInput(
+ text: string,
+ suffix: string,
+ timestamp: string
+): { entries: ParsedEntry[]; errors: string[] } {
+ const lines = text.split('\n')
+ const entries: ParsedEntry[] = []
+ const errors: string[] = []
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim()
+ if (!line) continue
+
+ // Support both tab and multi-space separation
+ const parts = line.split(/\t+|\s{2,}/)
+ if (parts.length < 2) {
+ errors.push(`Line ${i + 1}: Expected format "balancekey", got "${line.substring(0, 50)}"`)
+ continue
+ }
+
+ const balanceStr = parts[0].trim()
+ const key = parts.slice(1).join('').trim()
+
+ const balance = Number(balanceStr)
+ if (isNaN(balance)) {
+ errors.push(`Line ${i + 1}: Invalid balance "${balanceStr}"`)
+ continue
+ }
+
+ if (!key) {
+ errors.push(`Line ${i + 1}: Empty key`)
+ continue
+ }
+
+ entries.push({
+ balance,
+ key,
+ name: generateChannelName(balance, suffix, timestamp),
+ lineNumber: i + 1,
+ })
+ }
+
+ return { entries, errors }
+}
+
+// ============================================================================
+// Component
+// ============================================================================
+
+type BatchImportDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function BatchImportDialog({
+ open,
+ onOpenChange,
+}: BatchImportDialogProps) {
+ const { t } = useTranslation()
+ const queryClient = useQueryClient()
+
+ // Form state
+ const [inputText, setInputText] = useState('')
+ const [nameSuffix, setNameSuffix] = useState('')
+ const [models, setModels] = useState(DEFAULT_MODELS)
+ const [group, setGroup] = useState(DEFAULT_GROUP)
+
+ // Import state
+ const [importState, setImportState] = useState('idle')
+ const [results, setResults] = useState([])
+ const [progress, setProgress] = useState(0)
+
+ // Generate timestamp once for preview consistency
+ const timestamp = useMemo(() => generateTimestamp(), [open]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Parse input for preview
+ const parsed = useMemo(() => {
+ if (!inputText.trim() || !nameSuffix.trim()) {
+ return { entries: [], errors: [] }
+ }
+ return parseBatchInput(inputText, nameSuffix.trim(), timestamp)
+ }, [inputText, nameSuffix, timestamp])
+
+ // Reset state when dialog opens/closes
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (!isOpen) {
+ // Only reset if not currently importing
+ if (importState !== 'importing') {
+ setInputText('')
+ setNameSuffix('')
+ setModels(DEFAULT_MODELS)
+ setGroup(DEFAULT_GROUP)
+ setImportState('idle')
+ setResults([])
+ setProgress(0)
+ }
+ }
+ onOpenChange(isOpen)
+ },
+ [importState, onOpenChange]
+ )
+
+ // Execute import
+ const handleImport = useCallback(async () => {
+ if (parsed.entries.length === 0) return
+
+ setImportState('importing')
+ setResults([])
+ setProgress(0)
+
+ const importResults: ImportResult[] = []
+ const total = parsed.entries.length
+
+ // Use sequential requests to avoid overwhelming the server
+ for (let i = 0; i < total; i++) {
+ const entry = parsed.entries[i]
+ try {
+ const res = await createChannel({
+ mode: 'single',
+ channel: {
+ name: entry.name,
+ type: ANTHROPIC_CHANNEL_TYPE,
+ key: entry.key,
+ models: models,
+ group: group,
+ balance: entry.balance,
+ status: 1,
+ auto_ban: 1,
+ weight: 0,
+ priority: 0,
+ },
+ })
+
+ if (res.success) {
+ importResults.push({ entry, success: true })
+ } else {
+ importResults.push({
+ entry,
+ success: false,
+ error: res.message || 'Unknown error',
+ })
+ }
+ } catch (err) {
+ importResults.push({
+ entry,
+ success: false,
+ error: err instanceof Error ? err.message : 'Network error',
+ })
+ }
+
+ setProgress(i + 1)
+ setResults([...importResults])
+ }
+
+ setImportState('done')
+
+ const successCount = importResults.filter((r) => r.success).length
+ const failCount = importResults.filter((r) => !r.success).length
+
+ if (failCount === 0) {
+ toast.success(
+ t('Successfully imported {{count}} channels', { count: successCount })
+ )
+ } else {
+ toast.warning(
+ t('Imported {{success}} channels, {{fail}} failed', {
+ success: successCount,
+ fail: failCount,
+ })
+ )
+ }
+
+ // Refresh channel list
+ queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
+ }, [parsed.entries, models, group, queryClient, t])
+
+ const canImport =
+ importState === 'idle' &&
+ parsed.entries.length > 0 &&
+ parsed.errors.length === 0 &&
+ nameSuffix.trim().length > 0
+
+ const successCount = results.filter((r) => r.success).length
+ const failCount = results.filter((r) => !r.success).length
+
+ return (
+
+ )
+}
diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts
index 4f0e5042511d..02fc875384ba 100644
--- a/web/default/src/features/channels/lib/channel-form.ts
+++ b/web/default/src/features/channels/lib/channel-form.ts
@@ -269,7 +269,7 @@ export type ChannelFormValues = z.infer
export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
name: '',
- type: 1,
+ type: 14,
base_url: '',
key: '',
openai_organization: '',
From e67f912622c9307f84639a069a783f86571d66be Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 12:43:54 +0800
Subject: [PATCH 08/38] chore: remove models UI from batch import dialog &
optimize classic production build
---
web/classic/bun.lock | 1 +
web/classic/vite.config.js | 8 ++++----
web/default/bun.lock | 1 +
.../components/dialogs/batch-import-dialog.tsx | 14 +-------------
4 files changed, 7 insertions(+), 17 deletions(-)
diff --git a/web/classic/bun.lock b/web/classic/bun.lock
index 2b5b7a77bf22..fdaeec102f4c 100644
--- a/web/classic/bun.lock
+++ b/web/classic/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
diff --git a/web/classic/vite.config.js b/web/classic/vite.config.js
index 73e46212a587..36aaef0afca7 100644
--- a/web/classic/vite.config.js
+++ b/web/classic/vite.config.js
@@ -25,14 +25,14 @@ import { codeInspectorPlugin } from 'code-inspector-plugin';
const { vitePluginSemi } = pkg;
// https://vitejs.dev/config/
-export default defineConfig({
+export default defineConfig(({ command }) => ({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [
- codeInspectorPlugin({
+ command === 'serve' && codeInspectorPlugin({
bundler: 'vite',
}),
{
@@ -54,7 +54,7 @@ export default defineConfig({
vitePluginSemi({
cssLayer: true,
}),
- ],
+ ].filter(Boolean),
optimizeDeps: {
force: true,
esbuildOptions: {
@@ -104,4 +104,4 @@ export default defineConfig({
},
},
},
-});
+}));
diff --git a/web/default/bun.lock b/web/default/bun.lock
index 815e17f2ba77..e35c10d6eddc 100644
--- a/web/default/bun.lock
+++ b/web/default/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "newapi-web",
diff --git a/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx b/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
index 344e9c863811..169e4b2258a5 100644
--- a/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx
@@ -310,19 +310,7 @@ export function BatchImportDialog({
- {/* Models */}
-
-
-
+
{/* Group */}
From 1bc776e105a637bee29f707786b685bdeb05e44f Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 12:58:28 +0800
Subject: [PATCH 09/38] perf: use native esbuild config for classic theme jsx
transform to fix docker build OOM
---
web/classic/vite.config.js | 20 +++++---------------
1 file changed, 5 insertions(+), 15 deletions(-)
diff --git a/web/classic/vite.config.js b/web/classic/vite.config.js
index 36aaef0afca7..84f701626270 100644
--- a/web/classic/vite.config.js
+++ b/web/classic/vite.config.js
@@ -31,25 +31,15 @@ export default defineConfig(({ command }) => ({
'@': path.resolve(__dirname, './src'),
},
},
+ esbuild: {
+ loader: 'jsx',
+ include: /src\/.*\.[tj]sx?$/,
+ exclude: [],
+ },
plugins: [
command === 'serve' && codeInspectorPlugin({
bundler: 'vite',
}),
- {
- name: 'treat-js-files-as-jsx',
- async transform(code, id) {
- if (!/src\/.*\.js$/.test(id)) {
- return null;
- }
-
- // Use the exposed transform from vite, instead of directly
- // transforming with esbuild
- return transformWithEsbuild(code, id, {
- loader: 'jsx',
- jsx: 'automatic',
- });
- },
- },
react(),
vitePluginSemi({
cssLayer: true,
From 70c23986726dc3553494d1b68039c42a268a983c Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 13:08:14 +0800
Subject: [PATCH 10/38] perf: configure Bun GC limits & disable rollup cache to
resolve Docker build OOM
---
Dockerfile | 4 ++--
web/classic/vite.config.js | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 17c4398d4eb0..c679c2d71121 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,7 +6,7 @@ COPY web/default/bun.lock .
RUN bun install
COPY ./web/default .
COPY ./VERSION .
-RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
+RUN DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION=$(cat VERSION) bun --smol run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
@@ -16,7 +16,7 @@ COPY web/classic/bun.lock .
RUN bun install
COPY ./web/classic .
COPY ./VERSION .
-RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
+RUN BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION=$(cat VERSION) bun --smol run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
diff --git a/web/classic/vite.config.js b/web/classic/vite.config.js
index 84f701626270..b8154c5e89dd 100644
--- a/web/classic/vite.config.js
+++ b/web/classic/vite.config.js
@@ -55,7 +55,9 @@ export default defineConfig(({ command }) => ({
},
},
build: {
+ sourcemap: false,
rollupOptions: {
+ cache: false,
output: {
manualChunks: {
'react-core': ['react', 'react-dom', 'react-router-dom'],
From 17dc5e575c50df86072bbddb798654314493246f Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 13:14:46 +0800
Subject: [PATCH 11/38] ci: add GitHub Container Registry (GHCR) support to
Docker workflows
---
.github/workflows/docker-build.yml | 49 +++++++++++++++++++++-
.github/workflows/docker-image-nightly.yml | 43 ++++++++++++++++++-
2 files changed, 89 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 1601b86c2e0f..39196a73f1b1 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -58,6 +58,9 @@ jobs:
echo "${TAG}" > VERSION
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
+ - name: Normalize GHCR repository
+ run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -67,11 +70,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
- images: calciumion/new-api
+ images: |
+ calciumion/new-api
+ ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push
id: build
@@ -83,6 +95,8 @@ jobs:
tags: |
calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
calciumion/new-api:latest-${{ matrix.arch }}
+ ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
+ ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -93,7 +107,9 @@ jobs:
uses: sigstore/cosign-installer@v3
- name: Sign image with cosign
- run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
+ run: |
+ cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
+ cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
- name: Image summary
run: |
@@ -108,17 +124,30 @@ jobs:
needs: [build_single_arch]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
+ permissions:
+ packages: write
+ contents: read
steps:
- name: Set version
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
+ - name: Normalize GHCR repository
+ run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Create & push manifest (version)
run: |
docker buildx imagetools create \
@@ -133,9 +162,25 @@ jobs:
calciumion/new-api:latest-amd64 \
calciumion/new-api:latest-arm64
+ - name: Create & push manifest (GHCR version)
+ run: |
+ docker buildx imagetools create \
+ -t ghcr.io/${GHCR_REPOSITORY}:${TAG} \
+ ghcr.io/${GHCR_REPOSITORY}:${TAG}-amd64 \
+ ghcr.io/${GHCR_REPOSITORY}:${TAG}-arm64
+
+ - name: Create & push manifest (GHCR latest)
+ run: |
+ docker buildx imagetools create \
+ -t ghcr.io/${GHCR_REPOSITORY}:latest \
+ ghcr.io/${GHCR_REPOSITORY}:latest-amd64 \
+ ghcr.io/${GHCR_REPOSITORY}:latest-arm64
+
- name: Manifest summary
run: |
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
+ echo "---" >> $GITHUB_STEP_SUMMARY
+ docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:${TAG} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/docker-image-nightly.yml b/.github/workflows/docker-image-nightly.yml
index 2125fa9dd925..e85bf2ed9b10 100644
--- a/.github/workflows/docker-image-nightly.yml
+++ b/.github/workflows/docker-image-nightly.yml
@@ -26,6 +26,7 @@ jobs:
runs-on: ${{ matrix.runner }}
permissions:
+ packages: write
contents: read
steps:
@@ -43,6 +44,9 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Publishing version: $VERSION for ${{ matrix.arch }}"
+ - name: Normalize GHCR repository
+ run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -52,12 +56,20 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Extract metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: |
calciumion/new-api
+ ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push single-arch
uses: docker/build-push-action@v6
@@ -68,6 +80,8 @@ jobs:
tags: |
calciumion/new-api:nightly-${{ matrix.arch }}
calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
+ ghcr.io/${{ env.GHCR_REPOSITORY }}:nightly-${{ matrix.arch }}
+ ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -75,9 +89,12 @@ jobs:
sbom: false
create_manifests:
- name: Create multi-arch manifests (Docker Hub)
+ name: Create multi-arch manifests (Docker Hub + GHCR)
needs: [build_single_arch]
runs-on: ubuntu-latest
+ permissions:
+ packages: write
+ contents: read
steps:
- name: Check out (shallow)
@@ -92,12 +109,22 @@ jobs:
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
+ - name: Normalize GHCR repository
+ run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
+
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
- name: Create & push manifest (Docker Hub - nightly)
run: |
docker buildx imagetools create \
@@ -111,3 +138,17 @@ jobs:
-t calciumion/new-api:${VERSION} \
calciumion/new-api:${VERSION}-amd64 \
calciumion/new-api:${VERSION}-arm64
+
+ - name: Create & push manifest (GHCR - nightly)
+ run: |
+ docker buildx imagetools create \
+ -t ghcr.io/${GHCR_REPOSITORY}:nightly \
+ ghcr.io/${GHCR_REPOSITORY}:nightly-amd64 \
+ ghcr.io/${GHCR_REPOSITORY}:nightly-arm64
+
+ - name: Create & push manifest (GHCR - versioned nightly)
+ run: |
+ docker buildx imagetools create \
+ -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \
+ ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \
+ ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64
From 0dec4d532dd9a0bd0c8e7501b7a49872cb25335d Mon Sep 17 00:00:00 2001
From: z23cc
Date: Mon, 1 Jun 2026 13:19:25 +0800
Subject: [PATCH 12/38] ci: remove all Docker Hub logins, tags, and manifest
push steps to build solely for GHCR
---
.github/workflows/docker-build.yml | 34 +---------------------
.github/workflows/docker-image-alpha.yml | 33 ---------------------
.github/workflows/docker-image-nightly.yml | 29 ------------------
3 files changed, 1 insertion(+), 95 deletions(-)
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 39196a73f1b1..e931fab0bf4f 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -64,12 +64,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -82,7 +76,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
- calciumion/new-api
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push
@@ -93,8 +86,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: true
tags: |
- calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}
- calciumion/new-api:latest-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ env.TAG }}-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:latest-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
@@ -108,14 +99,13 @@ jobs:
- name: Sign image with cosign
run: |
- cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
- name: Image summary
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- echo "calciumion/new-api:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
+ echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:${TAG}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
@@ -135,12 +125,6 @@ jobs:
- name: Normalize GHCR repository
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -148,20 +132,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Create & push manifest (version)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:${TAG} \
- calciumion/new-api:${TAG}-amd64 \
- calciumion/new-api:${TAG}-arm64
-
- - name: Create & push manifest (latest)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:latest \
- calciumion/new-api:latest-amd64 \
- calciumion/new-api:latest-arm64
-
- name: Create & push manifest (GHCR version)
run: |
docker buildx imagetools create \
@@ -180,7 +150,5 @@ jobs:
run: |
echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY
- echo "---" >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:${TAG} >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml
index 116dd1452152..612aa09b5efa 100644
--- a/.github/workflows/docker-image-alpha.yml
+++ b/.github/workflows/docker-image-alpha.yml
@@ -49,12 +49,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- - name: Log in to Docker Hub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -67,7 +61,6 @@ jobs:
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: |
- calciumion/new-api
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push single-arch (to both registries)
@@ -78,8 +71,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: true
tags: |
- calciumion/new-api:alpha-${{ matrix.arch }}
- calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
@@ -93,14 +84,12 @@ jobs:
- name: Sign image with cosign
run: |
- cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }}
- name: Output digest
run: |
echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
@@ -128,26 +117,6 @@ jobs:
echo "value=$VERSION" >> $GITHUB_OUTPUT
echo "VERSION=$VERSION" >> $GITHUB_ENV
- - name: Log in to Docker Hub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Create & push manifest (Docker Hub - alpha)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:alpha \
- calciumion/new-api:alpha-amd64 \
- calciumion/new-api:alpha-arm64
-
- - name: Create & push manifest (Docker Hub - versioned alpha)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:${VERSION} \
- calciumion/new-api:${VERSION}-amd64 \
- calciumion/new-api:${VERSION}-arm64
-
- name: Log in to GHCR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
@@ -173,7 +142,5 @@ jobs:
run: |
echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY
- echo "---" >> $GITHUB_STEP_SUMMARY
docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/docker-image-nightly.yml b/.github/workflows/docker-image-nightly.yml
index e85bf2ed9b10..2587ba684ce8 100644
--- a/.github/workflows/docker-image-nightly.yml
+++ b/.github/workflows/docker-image-nightly.yml
@@ -50,12 +50,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -68,7 +62,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: |
- calciumion/new-api
ghcr.io/${{ env.GHCR_REPOSITORY }}
- name: Build & push single-arch
@@ -78,8 +71,6 @@ jobs:
platforms: ${{ matrix.platform }}
push: true
tags: |
- calciumion/new-api:nightly-${{ matrix.arch }}
- calciumion/new-api:${{ steps.version.outputs.value }}-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:nightly-${{ matrix.arch }}
ghcr.io/${{ env.GHCR_REPOSITORY }}:${{ steps.version.outputs.value }}-${{ matrix.arch }}
labels: ${{ steps.meta.outputs.labels }}
@@ -112,12 +103,6 @@ jobs:
- name: Normalize GHCR repository
run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
- - name: Log in to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- name: Log in to GHCR
uses: docker/login-action@v3
with:
@@ -125,20 +110,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Create & push manifest (Docker Hub - nightly)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:nightly \
- calciumion/new-api:nightly-amd64 \
- calciumion/new-api:nightly-arm64
-
- - name: Create & push manifest (Docker Hub - versioned nightly)
- run: |
- docker buildx imagetools create \
- -t calciumion/new-api:${VERSION} \
- calciumion/new-api:${VERSION}-amd64 \
- calciumion/new-api:${VERSION}-arm64
-
- name: Create & push manifest (GHCR - nightly)
run: |
docker buildx imagetools create \
From 12dbd793a47a2251d893a3b83cd5ce335d9457b2 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 10:21:48 +0800
Subject: [PATCH 13/38] feat: improve classic channel quota controls
---
controller/channel-billing.go | 22 +++
model/channel.go | 4 +
router/api-router.go | 1 +
.../table/channels/ChannelsColumnDefs.jsx | 22 +++
.../table/channels/ChannelsTable.jsx | 3 +
.../channels/modals/BatchImportModal.jsx | 185 ++++++++++++++----
.../src/hooks/channels/useChannelsData.jsx | 18 ++
web/classic/src/i18n/locales/en.json | 8 +-
web/classic/src/i18n/locales/fr.json | 10 +-
web/classic/src/i18n/locales/ja.json | 10 +-
web/classic/src/i18n/locales/ru.json | 10 +-
web/classic/src/i18n/locales/vi.json | 8 +-
web/classic/src/i18n/locales/zh-CN.json | 8 +-
web/classic/src/i18n/locales/zh-TW.json | 8 +-
web/classic/src/i18n/locales/zh.json | 8 +-
15 files changed, 278 insertions(+), 47 deletions(-)
diff --git a/controller/channel-billing.go b/controller/channel-billing.go
index 751ee3600ac9..cf67081e1c2a 100644
--- a/controller/channel-billing.go
+++ b/controller/channel-billing.go
@@ -421,6 +421,28 @@ func updateChannelBalance(channel *model.Channel) (float64, error) {
return balance, nil
}
+func ClearChannelUsedQuota(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ _, err = model.GetChannelById(id, false)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err = model.ResetChannelUsedQuota(id); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "used_quota": 0,
+ })
+}
+
func UpdateChannelBalance(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
diff --git a/model/channel.go b/model/channel.go
index 78a1477c327e..57eb5c3ee722 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -867,6 +867,10 @@ func updateChannelUsedQuota(id int, quota int) {
}
}
+func ResetChannelUsedQuota(id int) error {
+ return DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", 0).Error
+}
+
func DeleteChannelByStatus(status int64) (int64, error) {
result := DB.Where("status = ?", status).Delete(&Channel{})
return result.RowsAffected, result.Error
diff --git a/router/api-router.go b/router/api-router.go
index 32b7d469124e..02837951d6d8 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -256,6 +256,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)
channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance)
+ channelRoute.POST("/used_quota/clear/:id", controller.ClearChannelUsedQuota)
channelRoute.POST("/", controller.AddChannel)
channelRoute.PUT("/", controller.UpdateChannel)
channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel)
diff --git a/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx b/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx
index 5d748c0f5343..a3693cffca8c 100644
--- a/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx
+++ b/web/classic/src/components/table/channels/ChannelsColumnDefs.jsx
@@ -308,6 +308,7 @@ export const getChannelsColumns = ({
t,
COLUMN_KEYS,
updateChannelBalance,
+ clearChannelUsedQuota,
manageChannel,
manageTag,
submitTagEdit,
@@ -558,6 +559,27 @@ export const getChannelsColumns = ({
: renderQuotaWithAmount(record.balance)}
+ {
+ e.stopPropagation();
+ if ((record.used_quota || 0) <= 0) return;
+ Modal.confirm({
+ title: t('确定要清空该渠道已用额度?'),
+ content: t(
+ '此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。',
+ ),
+ okText: t('清空'),
+ cancelText: t('取消'),
+ onOk: () => clearChannelUsedQuota(record),
+ });
+ }}
+ >
+ {t('清空')}
+
);
diff --git a/web/classic/src/components/table/channels/ChannelsTable.jsx b/web/classic/src/components/table/channels/ChannelsTable.jsx
index 09b486e32ab7..13976543440e 100644
--- a/web/classic/src/components/table/channels/ChannelsTable.jsx
+++ b/web/classic/src/components/table/channels/ChannelsTable.jsx
@@ -45,6 +45,7 @@ const ChannelsTable = (channelsData) => {
COLUMN_KEYS,
// Column functions and data
updateChannelBalance,
+ clearChannelUsedQuota,
manageChannel,
manageTag,
submitTagEdit,
@@ -71,6 +72,7 @@ const ChannelsTable = (channelsData) => {
t,
COLUMN_KEYS,
updateChannelBalance,
+ clearChannelUsedQuota,
manageChannel,
manageTag,
submitTagEdit,
@@ -95,6 +97,7 @@ const ChannelsTable = (channelsData) => {
t,
COLUMN_KEYS,
updateChannelBalance,
+ clearChannelUsedQuota,
manageChannel,
manageTag,
submitTagEdit,
diff --git a/web/classic/src/components/table/channels/modals/BatchImportModal.jsx b/web/classic/src/components/table/channels/modals/BatchImportModal.jsx
index 6e09b7ee869d..4d1905d690eb 100644
--- a/web/classic/src/components/table/channels/modals/BatchImportModal.jsx
+++ b/web/classic/src/components/table/channels/modals/BatchImportModal.jsx
@@ -17,11 +17,13 @@ along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
-import React, { useState, useMemo, useCallback } from 'react';
+import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Input,
+ InputNumber,
+ Select,
Button,
Table,
Typography,
@@ -32,7 +34,7 @@ import {
TextArea,
} from '@douyinfe/semi-ui';
import { IconUpload } from '@douyinfe/semi-icons';
-import { API, showSuccess, showError } from '../../../../helpers';
+import { API, showSuccess, showError, selectFilter } from '../../../../helpers';
import { getChannelModels } from '../../../../helpers';
const { Text } = Typography;
@@ -114,6 +116,11 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
const [nameSuffix, setNameSuffix] = useState('');
const [models, setModels] = useState('');
const [group, setGroup] = useState(DEFAULT_GROUP);
+ const [priority, setPriority] = useState(0);
+ const [weight, setWeight] = useState(0);
+ const [groupOptions, setGroupOptions] = useState([
+ { label: DEFAULT_GROUP, value: DEFAULT_GROUP },
+ ]);
// Import state
const [importState, setImportState] = useState('idle'); // idle | importing | done
@@ -128,6 +135,30 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
return getChannelModels(ANTHROPIC_CHANNEL_TYPE).join(',');
}, []);
+ const fetchGroups = useCallback(async () => {
+ try {
+ const res = await API.get('/api/group/');
+ const groups = Array.isArray(res?.data?.data) ? res.data.data : [];
+ const uniqueGroups = Array.from(
+ new Set([DEFAULT_GROUP, ...groups].filter(Boolean)),
+ );
+ setGroupOptions(
+ uniqueGroups.map((item) => ({
+ label: item,
+ value: item,
+ })),
+ );
+ } catch (error) {
+ showError(error.message);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (visible) {
+ fetchGroups();
+ }
+ }, [visible, fetchGroups]);
+
// Parse input for preview
const parsed = useMemo(() => {
if (!inputText.trim() || !nameSuffix.trim()) {
@@ -142,6 +173,8 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
setNameSuffix('');
setModels('');
setGroup(DEFAULT_GROUP);
+ setPriority(0);
+ setWeight(0);
setImportState('idle');
setResults([]);
setProgress(0);
@@ -180,8 +213,8 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
balance: entry.balance,
status: 1,
auto_ban: 1,
- weight: 0,
- priority: 0,
+ weight: Number(weight) || 0,
+ priority: Number(priority) || 0,
},
});
@@ -212,7 +245,9 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
const failCount = importResults.filter((r) => !r.success).length;
if (failCount === 0) {
- showSuccess(t('成功导入 {{count}} 个渠道').replace('{{count}}', successCount));
+ showSuccess(
+ t('成功导入 {{count}} 个渠道').replace('{{count}}', successCount),
+ );
} else {
showError(
t('导入完成:成功 {{success}} 个,失败 {{fail}} 个')
@@ -222,7 +257,16 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
}
if (onSuccess) onSuccess();
- }, [parsed.entries, models, defaultModels, group, onSuccess, t]);
+ }, [
+ parsed.entries,
+ models,
+ defaultModels,
+ group,
+ weight,
+ priority,
+ onSuccess,
+ t,
+ ]);
const canImport =
importState === 'idle' &&
@@ -245,7 +289,11 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
title: t('渠道名称'),
dataIndex: 'name',
width: 220,
- render: (text) => {text},
+ render: (text) => (
+
+ {text}
+
+ ),
},
{
title: t('余额'),
@@ -258,7 +306,13 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
title: t('密钥前缀'),
dataIndex: 'key',
render: (text) => (
-
+
{text.substring(0, 20)}...
),
@@ -276,17 +330,23 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
const result = results[index];
if (!result) {
return index < progress ? (
- {t('进行中')}
+
+ {t('进行中')}
+
) : (
- {t('等待')}
+
+ {t('等待')}
+
);
}
return result.success ? (
- {t('成功')}
+
+ {t('成功')}
+
) : (
showError(result.error)}
>
@@ -312,23 +372,23 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
width={700}
footer={
-
+
{importState === 'done' ? t('关闭') : t('取消')}
{importState !== 'done' && (
{importState === 'importing'
? t('导入中...')
- : t('导入 ({{count}} 条)').replace('{{count}}', parsed.entries.length)}
+ : t('导入 ({{count}} 条)').replace(
+ '{{count}}',
+ parsed.entries.length,
+ )}
)}
@@ -337,14 +397,22 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
{/* Name Tag */}
-
{t('名称标签')}
+
+ {t('名称标签')}
+
-
+
{t('渠道命名格式:{{format}}').replace(
'{{format}}',
`${timestamp}-{余额}-{标签}`,
@@ -354,20 +422,57 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
{/* Group */}
-
{t('分组')}
-
+ {t('分组')}
+
+
+ {/* Priority and Weight */}
+
+
+
+ {t('优先级')}
+
+
setPriority(value ?? 0)}
+ disabled={importState !== 'idle'}
+ min={-999}
+ style={{ width: '100%' }}
+ />
+
+
+
+ {t('权重')}
+
+
setWeight(value ?? 0)}
+ disabled={importState !== 'idle'}
+ min={0}
+ style={{ width: '100%' }}
+ />
+
+
+
{/* Input Data */}
{t('导入数据')}{' '}
-
+
({t('余额密钥,每行一条')})
@@ -384,7 +489,7 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
{/* Parse Errors */}
{parsed.errors.length > 0 && (
@@ -392,7 +497,8 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
{parsed.errors.map((err, i) => (
- {t('第 {{line}} 行', { line: '' })}{err}
+ {t('第 {{line}} 行', { line: '' })}
+ {err}
))}
@@ -412,18 +518,21 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
}}
>
{t('预览')}
-
- {t('共 {{count}} 条').replace('{{count}}', parsed.entries.length)}
+
+ {t('共 {{count}} 条').replace(
+ '{{count}}',
+ parsed.entries.length,
+ )}
)}
@@ -438,10 +547,10 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
marginBottom: 4,
}}
>
-
+
{t('导入中...')} {progress}/{parsed.entries.length}
-
+
{Math.round((progress / parsed.entries.length) * 100)}%
@@ -459,12 +568,16 @@ const BatchImportModal = ({ visible, onCancel, onSuccess }) => {
description={
- {t('✓')}
+
+ {t('✓')}
+
{t('成功 {{count}} 个').replace('{{count}}', successCount)}
{failCount > 0 && (
- {t('✗')}
+
+ {t('✗')}
+
{t('失败 {{count}} 个').replace('{{count}}', failCount)}
)}
diff --git a/web/classic/src/hooks/channels/useChannelsData.jsx b/web/classic/src/hooks/channels/useChannelsData.jsx
index 0ce46ea064ac..8263bbab6525 100644
--- a/web/classic/src/hooks/channels/useChannelsData.jsx
+++ b/web/classic/src/hooks/channels/useChannelsData.jsx
@@ -783,6 +783,23 @@ export const useChannelsData = () => {
}
};
+ const clearChannelUsedQuota = async (record) => {
+ const res = await API.post(`/api/channel/used_quota/clear/${record.id}`);
+ const { success, message } = res.data;
+ if (success) {
+ if (enableTagMode) {
+ await refresh();
+ } else {
+ updateChannelProperty(record.id, (channel) => {
+ channel.used_quota = 0;
+ });
+ }
+ showSuccess(t('已用额度已清空'));
+ } else {
+ showError(message || t('清空已用额度失败'));
+ }
+ };
+
const fixChannelsAbilities = async () => {
const res = await API.post(`/api/channel/fix`);
const { success, message, data } = res.data;
@@ -1236,6 +1253,7 @@ export const useChannelsData = () => {
deleteAllDisabledChannels,
updateAllChannelsBalance,
updateChannelBalance,
+ clearChannelUsedQuota,
fixChannelsAbilities,
checkOllamaVersion,
testChannel,
diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json
index 17511d2a5552..400613be9185 100644
--- a/web/classic/src/i18n/locales/en.json
+++ b/web/classic/src/i18n/locales/en.json
@@ -3825,6 +3825,12 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
- "、": ", "
+ "、": ", ",
+ "清空已用额度": "Clear used quota",
+ "确定要清空该渠道已用额度?": "Clear this channel used quota?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "This will reset this channel's used quota to 0 and will not affect the remaining quota.",
+ "已用额度已清空": "Used quota cleared",
+ "清空已用额度失败": "Failed to clear used quota",
+ "已用额度已为 0": "Used quota is already 0"
}
}
diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json
index a24d32bad00c..2f1c88bbb314 100644
--- a/web/classic/src/i18n/locales/fr.json
+++ b/web/classic/src/i18n/locales/fr.json
@@ -2300,7 +2300,7 @@
"清理失败": "Échec du nettoyage",
"清理方式": "Mode de nettoyage",
"清理日志文件": "Nettoyer les fichiers journaux",
- "清空": "Clear",
+ "清空": "Effacer",
"清空全部缓存": "Vider tout le cache",
"清空该规则缓存": "Vider le cache de cette règle",
"清空重定向": "Effacer la redirection",
@@ -3679,6 +3679,12 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
- "、": ", "
+ "、": ", ",
+ "清空已用额度": "Effacer le quota utilisé",
+ "确定要清空该渠道已用额度?": "Effacer le quota utilisé de ce canal ?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "Cette opération réinitialisera le quota utilisé de ce canal à 0 sans affecter le quota restant.",
+ "已用额度已清空": "Quota utilisé effacé",
+ "清空已用额度失败": "Échec de l’effacement du quota utilisé",
+ "已用额度已为 0": "Le quota utilisé est déjà à 0"
}
}
diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json
index dde2a1a578e2..c9bc3b451c29 100644
--- a/web/classic/src/i18n/locales/ja.json
+++ b/web/classic/src/i18n/locales/ja.json
@@ -2271,7 +2271,7 @@
"清理失败": "クリーンアップに失敗しました",
"清理方式": "クリーンアップモード",
"清理日志文件": "ログファイルをクリーンアップ",
- "清空": "Clear",
+ "清空": "クリア",
"清空全部缓存": "すべてのキャッシュをクリア",
"清空该规则缓存": "このルールのキャッシュをクリア",
"清空重定向": "マッピングをクリア",
@@ -3648,6 +3648,12 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
- "、": ", "
+ "、": ", ",
+ "清空已用额度": "使用済みクォータをクリア",
+ "确定要清空该渠道已用额度?": "このチャンネルの使用済みクォータをクリアしますか?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "この操作は、このチャンネルの使用済みクォータを 0 にリセットします。残りクォータには影響しません。",
+ "已用额度已清空": "使用済みクォータをクリアしました",
+ "清空已用额度失败": "使用済みクォータのクリアに失敗しました",
+ "已用额度已为 0": "使用済みクォータはすでに 0 です"
}
}
diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json
index b934dfe1bc5c..379b0c4d3ade 100644
--- a/web/classic/src/i18n/locales/ru.json
+++ b/web/classic/src/i18n/locales/ru.json
@@ -2318,7 +2318,7 @@
"清理失败": "Ошибка очистки",
"清理方式": "Режим очистки",
"清理日志文件": "Очистить файлы журналов",
- "清空": "Clear",
+ "清空": "Очистить",
"清空全部缓存": "Очистить весь кэш",
"清空该规则缓存": "Очистить кэш этого правила",
"清空重定向": "Очистить перенаправление",
@@ -3699,6 +3699,12 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
- "、": ", "
+ "、": ", ",
+ "清空已用额度": "Очистить использованную квоту",
+ "确定要清空该渠道已用额度?": "Очистить использованную квоту этого канала?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "Это действие сбросит использованную квоту этого канала до 0 и не повлияет на оставшуюся квоту.",
+ "已用额度已清空": "Использованная квота очищена",
+ "清空已用额度失败": "Не удалось очистить использованную квоту",
+ "已用额度已为 0": "Использованная квота уже равна 0"
}
}
diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json
index 771a25fcf201..54a2dcadb4f5 100644
--- a/web/classic/src/i18n/locales/vi.json
+++ b/web/classic/src/i18n/locales/vi.json
@@ -4213,6 +4213,12 @@
"并确认自行承担部署": "confirm that I bear legal responsibility arising from deployment",
"运营和收费行为产生的法律责任": "operation and charging behavior",
",": ", ",
- "、": ", "
+ "、": ", ",
+ "清空已用额度": "Xóa hạn mức đã dùng",
+ "确定要清空该渠道已用额度?": "Xóa hạn mức đã dùng của kênh này?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "Thao tác này sẽ đặt lại hạn mức đã dùng của kênh này về 0 và không ảnh hưởng đến hạn mức còn lại.",
+ "已用额度已清空": "Đã xóa hạn mức đã dùng",
+ "清空已用额度失败": "Xóa hạn mức đã dùng thất bại",
+ "已用额度已为 0": "Hạn mức đã dùng đã là 0"
}
}
diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json
index e1141b0414f7..e96dbb93b035 100644
--- a/web/classic/src/i18n/locales/zh-CN.json
+++ b/web/classic/src/i18n/locales/zh-CN.json
@@ -3808,6 +3808,12 @@
"并确认自行承担部署": "并确认自行承担部署",
"运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任",
",": ",",
- "、": "、"
+ "、": "、",
+ "清空已用额度": "清空已用额度",
+ "确定要清空该渠道已用额度?": "确定要清空该渠道已用额度?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。",
+ "已用额度已清空": "已用额度已清空",
+ "清空已用额度失败": "清空已用额度失败",
+ "已用额度已为 0": "已用额度已为 0"
}
}
diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json
index 3be48fb4dce5..bea0820e580f 100644
--- a/web/classic/src/i18n/locales/zh-TW.json
+++ b/web/classic/src/i18n/locales/zh-TW.json
@@ -3672,6 +3672,12 @@
"并确认自行承担部署": "並確認自行承擔部署",
"运营和收费行为产生的法律责任": "營運和收費行為產生的法律責任",
",": ",",
- "、": "、"
+ "、": "、",
+ "清空已用额度": "清空已用額度",
+ "确定要清空该渠道已用额度?": "確定要清空該渠道已用額度?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "此操作會將該渠道的已用額度重置為 0,不會影響剩餘額度。",
+ "已用额度已清空": "已用額度已清空",
+ "清空已用额度失败": "清空已用額度失敗",
+ "已用额度已为 0": "已用額度已為 0"
}
}
diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json
index b70e8ffb955c..1e340e8f6427 100644
--- a/web/classic/src/i18n/locales/zh.json
+++ b/web/classic/src/i18n/locales/zh.json
@@ -2625,6 +2625,12 @@
"并确认自行承担部署": "并确认自行承担部署",
"运营和收费行为产生的法律责任": "运营和收费行为产生的法律责任",
",": ",",
- "、": "、"
+ "、": "、",
+ "清空已用额度": "清空已用额度",
+ "确定要清空该渠道已用额度?": "确定要清空该渠道已用额度?",
+ "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。": "此操作会将该渠道的已用额度重置为 0,不会影响剩余额度。",
+ "已用额度已清空": "已用额度已清空",
+ "清空已用额度失败": "清空已用额度失败",
+ "已用额度已为 0": "已用额度已为 0"
}
}
From 94c669dc026af1e177df7b28c6c0d4d18967f27f Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 13:26:40 +0800
Subject: [PATCH 14/38] feat: add channel preparation pool
---
controller/channel.go | 118 +++---
controller/channel_preparation.go | 356 ++++++++++++++++++
model/channel.go | 31 +-
model/channel_preparation.go | 285 ++++++++++++++
model/main.go | 2 +
router/api-router.go | 8 +
web/classic/src/App.jsx | 9 +
.../src/components/layout/SiderBar.jsx | 7 +
.../PreparationActions.jsx | 57 +++
.../PreparationColumnDefs.jsx | 180 +++++++++
.../PreparationFilters.jsx | 74 ++++
.../channel-preparations/PreparationTable.jsx | 85 +++++
.../table/channel-preparations/index.jsx | 87 +++++
.../modals/EditPreparationModal.jsx | 250 ++++++++++++
.../modals/ImportPreparationModal.jsx | 266 +++++++++++++
web/classic/src/helpers/render.jsx | 1 +
.../channels/useChannelPreparationsData.jsx | 288 ++++++++++++++
web/classic/src/hooks/common/useSidebar.js | 1 +
web/classic/src/i18n/locales/en.json | 44 ++-
web/classic/src/i18n/locales/zh-CN.json | 44 ++-
web/classic/src/i18n/locales/zh-TW.json | 44 ++-
web/classic/src/i18n/locales/zh.json | 44 ++-
.../src/pages/ChannelPreparation/index.jsx | 12 +
23 files changed, 2228 insertions(+), 65 deletions(-)
create mode 100644 controller/channel_preparation.go
create mode 100644 model/channel_preparation.go
create mode 100644 web/classic/src/components/table/channel-preparations/PreparationActions.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/PreparationColumnDefs.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/PreparationTable.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/index.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
create mode 100644 web/classic/src/components/table/channel-preparations/modals/ImportPreparationModal.jsx
create mode 100644 web/classic/src/hooks/channels/useChannelPreparationsData.jsx
create mode 100644 web/classic/src/pages/ChannelPreparation/index.jsx
diff --git a/controller/channel.go b/controller/channel.go
index ed5d8185a424..505f27a7e810 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -454,14 +454,18 @@ func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
+ if channel == nil {
+ return fmt.Errorf("channel cannot be empty")
+ }
+
// 校验 channel settings
if err := channel.ValidateSettings(); err != nil {
return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
}
- // 如果是添加操作,检查 channel 和 key 是否为空
+ // 如果是添加操作,检查 key 是否为空
if isAdd {
- if channel == nil || channel.Key == "" {
+ if channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}
@@ -583,83 +587,64 @@ func getVertexArrayKeys(keys string) ([]string, error) {
return cleanKeys, nil
}
-func AddChannel(c *gin.Context) {
- addChannelRequest := AddChannelRequest{}
- err := c.ShouldBindJSON(&addChannelRequest)
- if err != nil {
- common.ApiError(c, err)
- return
+func buildChannelsFromAddRequest(addChannelRequest *AddChannelRequest) ([]model.Channel, error) {
+ if addChannelRequest == nil {
+ return nil, fmt.Errorf("channel cannot be empty")
}
-
- // 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": err.Error(),
- })
- return
+ return nil, err
}
- addChannelRequest.Channel.CreatedTime = common.GetTimestamp()
+ baseChannel := *addChannelRequest.Channel
+ baseChannel.CreatedTime = common.GetTimestamp()
keys := make([]string, 0)
switch addChannelRequest.Mode {
case "multi_to_single":
- addChannelRequest.Channel.ChannelInfo.IsMultiKey = true
- addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
- array, err := getVertexArrayKeys(addChannelRequest.Channel.Key)
+ baseChannel.ChannelInfo.IsMultiKey = true
+ baseChannel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode
+ if baseChannel.Type == constant.ChannelTypeVertexAi && baseChannel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
+ array, err := getVertexArrayKeys(baseChannel.Key)
if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": err.Error(),
- })
- return
+ return nil, err
}
- addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array)
- addChannelRequest.Channel.Key = strings.Join(array, "\n")
+ baseChannel.ChannelInfo.MultiKeySize = len(array)
+ baseChannel.Key = strings.Join(array, "\n")
} else {
cleanKeys := make([]string, 0)
- for _, key := range strings.Split(addChannelRequest.Channel.Key, "\n") {
+ for _, key := range strings.Split(baseChannel.Key, "\n") {
+ key = strings.TrimSpace(key)
if key == "" {
continue
}
- key = strings.TrimSpace(key)
cleanKeys = append(cleanKeys, key)
}
- addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys)
- addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n")
+ baseChannel.ChannelInfo.MultiKeySize = len(cleanKeys)
+ baseChannel.Key = strings.Join(cleanKeys, "\n")
}
- keys = []string{addChannelRequest.Channel.Key}
+ keys = []string{baseChannel.Key}
case "batch":
- if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
- // multi json
- keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key)
+ if baseChannel.Type == constant.ChannelTypeVertexAi && baseChannel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey {
+ array, err := getVertexArrayKeys(baseChannel.Key)
if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": err.Error(),
- })
- return
+ return nil, err
}
+ keys = array
} else {
- keys = strings.Split(addChannelRequest.Channel.Key, "\n")
+ keys = strings.Split(baseChannel.Key, "\n")
}
case "single":
- keys = []string{addChannelRequest.Channel.Key}
+ keys = []string{baseChannel.Key}
default:
- c.JSON(http.StatusOK, gin.H{
- "success": false,
- "message": "不支持的添加模式",
- })
- return
+ return nil, fmt.Errorf("不支持的添加模式")
}
channels := make([]model.Channel, 0, len(keys))
for _, key := range keys {
+ key = strings.TrimSpace(key)
if key == "" {
continue
}
- localChannel := addChannelRequest.Channel
+ localChannel := baseChannel
localChannel.Key = key
if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 {
keyPrefix := localChannel.Key
@@ -668,13 +653,46 @@ func AddChannel(c *gin.Context) {
}
localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix)
}
- channels = append(channels, *localChannel)
+ channels = append(channels, localChannel)
+ }
+ if len(channels) == 0 {
+ return nil, fmt.Errorf("channel cannot be empty")
+ }
+ return channels, nil
+}
+
+func createChannelsFromAddRequest(addChannelRequest *AddChannelRequest, tx *gorm.DB) ([]model.Channel, error) {
+ channels, err := buildChannelsFromAddRequest(addChannelRequest)
+ if err != nil {
+ return nil, err
+ }
+ if tx != nil {
+ err = model.CreateChannelsWithTx(tx, channels)
+ } else {
+ err = model.BatchInsertChannels(channels)
}
- err = model.BatchInsertChannels(channels)
+ if err != nil {
+ return nil, err
+ }
+ return channels, nil
+}
+
+func AddChannel(c *gin.Context) {
+ addChannelRequest := AddChannelRequest{}
+ err := c.ShouldBindJSON(&addChannelRequest)
if err != nil {
common.ApiError(c, err)
return
}
+
+ _, err = createChannelsFromAddRequest(&addChannelRequest, nil)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
service.ResetProxyClientCache()
c.JSON(http.StatusOK, gin.H{
"success": true,
diff --git a/controller/channel_preparation.go b/controller/channel_preparation.go
new file mode 100644
index 000000000000..52f90e3c2285
--- /dev/null
+++ b/controller/channel_preparation.go
@@ -0,0 +1,356 @@
+package controller
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+type channelPreparationImportRequest struct {
+ Items []model.ChannelPreparation `json:"items"`
+}
+
+type channelPreparationBatchRequest struct {
+ Ids []int `json:"ids"`
+}
+
+type channelPreparationImportResult struct {
+ Index int `json:"index"`
+ Name string `json:"name"`
+ Data *model.ChannelPreparationResponse `json:"data,omitempty"`
+ Ok bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+}
+
+type channelPreparationPromoteResult struct {
+ Id int `json:"id"`
+ ChannelId int `json:"channel_id,omitempty"`
+ Ok bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+}
+
+func parseOptionalIntQuery(c *gin.Context, name string) (*int, error) {
+ value := strings.TrimSpace(c.Query(name))
+ if value == "" {
+ return nil, nil
+ }
+ parsed, err := strconv.Atoi(value)
+ if err != nil {
+ return nil, err
+ }
+ return &parsed, nil
+}
+
+func defaultChannelPreparationModels(channelType int) string {
+ if channelType == 0 {
+ channelType = constant.ChannelTypeAnthropic
+ }
+ models := channelId2Models[channelType]
+ if len(models) == 0 {
+ models = channelId2Models[constant.ChannelTypeAnthropic]
+ }
+ return strings.Join(models, ",")
+}
+
+func applyChannelPreparationDefaults(preparation *model.ChannelPreparation) {
+ if preparation == nil {
+ return
+ }
+ if preparation.Type == 0 {
+ preparation.Type = constant.ChannelTypeAnthropic
+ }
+ if strings.TrimSpace(preparation.Models) == "" {
+ preparation.Models = defaultChannelPreparationModels(preparation.Type)
+ }
+}
+
+func validateChannelPreparationInput(preparation *model.ChannelPreparation, isCreate bool) error {
+ if preparation == nil {
+ return fmt.Errorf("preparation cannot be empty")
+ }
+ if strings.TrimSpace(preparation.Name) == "" {
+ return fmt.Errorf("name cannot be empty")
+ }
+ if isCreate && strings.TrimSpace(preparation.Key) == "" {
+ return fmt.Errorf("key cannot be empty")
+ }
+ if strings.TrimSpace(preparation.Group) == "" {
+ preparation.Group = "default"
+ }
+ applyChannelPreparationDefaults(preparation)
+ if preparation.Remark != nil && len(*preparation.Remark) > 255 {
+ return fmt.Errorf("remark is too long")
+ }
+ if preparation.Setting != nil {
+ channel := preparation.ToChannel()
+ if err := channel.ValidateSettings(); err != nil {
+ return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error())
+ }
+ }
+ return nil
+}
+
+func GetChannelPreparations(c *gin.Context) {
+ page, _ := strconv.Atoi(c.Query("p"))
+ pageSize, _ := strconv.Atoi(c.Query("page_size"))
+ channelType, err := parseOptionalIntQuery(c, "type")
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ status, err := parseOptionalIntQuery(c, "status")
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ opts := model.ChannelPreparationListOptions{
+ Page: page,
+ PageSize: pageSize,
+ Keyword: c.Query("keyword"),
+ Group: c.Query("group"),
+ Type: channelType,
+ Status: status,
+ IDSort: c.Query("id_sort") == "true" || c.Query("id_sort") == "1",
+ }
+ preparations, total, statusCounts, typeCounts, err := model.GetChannelPreparations(opts)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "items": model.ChannelPreparationResponses(preparations),
+ "total": total,
+ "page": opts.Page,
+ "page_size": opts.PageSize,
+ "status_counts": statusCounts,
+ "type_counts": typeCounts,
+ })
+}
+
+func GetChannelPreparation(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ var preparation model.ChannelPreparation
+ if err := model.DB.First(&preparation, "id = ?", id).Error; err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, preparation.ToResponse())
+}
+
+func AddChannelPreparation(c *gin.Context) {
+ var preparation model.ChannelPreparation
+ if err := c.ShouldBindJSON(&preparation); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := validateChannelPreparationInput(&preparation, true); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ preparation.NormalizeForCreate()
+ if err := model.DB.Create(&preparation).Error; err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, preparation.ToResponse())
+}
+
+func UpdateChannelPreparation(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ var input model.ChannelPreparation
+ if err := c.ShouldBindJSON(&input); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ var existing model.ChannelPreparation
+ if err := model.DB.First(&existing, "id = ?", id).Error; err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if existing.Status != model.ChannelPreparationStatusPending {
+ common.ApiErrorMsg(c, "只有待晋升的候选渠道可以编辑")
+ return
+ }
+ input.NormalizeForUpdate(&existing)
+ if err := validateChannelPreparationInput(&input, false); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ if err := model.DB.Save(&input).Error; err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, input.ToResponse())
+}
+
+func ArchiveChannelPreparation(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ updates := map[string]any{
+ "status": model.ChannelPreparationStatusArchived,
+ "updated_time": common.GetTimestamp(),
+ }
+ result := model.DB.Model(&model.ChannelPreparation{}).
+ Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPending).
+ Updates(updates)
+ if result.Error != nil {
+ common.ApiError(c, result.Error)
+ return
+ }
+ if result.RowsAffected == 0 {
+ common.ApiErrorMsg(c, "候选渠道不存在或不可归档")
+ return
+ }
+ common.ApiSuccess(c, gin.H{"id": id})
+}
+
+func ImportChannelPreparations(c *gin.Context) {
+ var request channelPreparationImportRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ results := make([]channelPreparationImportResult, 0, len(request.Items))
+ for index, item := range request.Items {
+ if strings.TrimSpace(item.Source) == "" {
+ item.Source = "batch_import"
+ }
+ if err := validateChannelPreparationInput(&item, true); err != nil {
+ results = append(results, channelPreparationImportResult{Index: index, Name: item.Name, Ok: false, Error: err.Error()})
+ continue
+ }
+ item.NormalizeForCreate()
+ if err := model.DB.Create(&item).Error; err != nil {
+ results = append(results, channelPreparationImportResult{Index: index, Name: item.Name, Ok: false, Error: err.Error()})
+ continue
+ }
+ response := item.ToResponse()
+ results = append(results, channelPreparationImportResult{Index: index, Name: item.Name, Data: &response, Ok: true})
+ }
+ common.ApiSuccess(c, gin.H{"results": results})
+}
+
+func promoteChannelPreparation(id int) (int, error) {
+ tx := model.DB.Begin()
+ if tx.Error != nil {
+ return 0, tx.Error
+ }
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ }
+ }()
+
+ now := common.GetTimestamp()
+ lockResult := tx.Model(&model.ChannelPreparation{}).
+ Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPending).
+ Updates(map[string]any{
+ "status": model.ChannelPreparationStatusPromoting,
+ "updated_time": now,
+ })
+ if lockResult.Error != nil {
+ tx.Rollback()
+ return 0, lockResult.Error
+ }
+ if lockResult.RowsAffected == 0 {
+ tx.Rollback()
+ return 0, fmt.Errorf("候选渠道不存在或不可晋升")
+ }
+
+ var preparation model.ChannelPreparation
+ if err := tx.First(&preparation, "id = ?", id).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ applyChannelPreparationDefaults(&preparation)
+ channel := preparation.ToChannel()
+ channels, err := createChannelsFromAddRequest(&AddChannelRequest{Mode: "single", Channel: channel}, tx)
+ if err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ if len(channels) == 0 {
+ tx.Rollback()
+ return 0, fmt.Errorf("channel cannot be empty")
+ }
+ channelID := channels[0].Id
+ updates := map[string]any{
+ "status": model.ChannelPreparationStatusPromoted,
+ "promoted_channel_id": channelID,
+ "promoted_time": now,
+ "updated_time": now,
+ }
+ promoteResult := tx.Model(&model.ChannelPreparation{}).
+ Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPromoting).
+ Updates(updates)
+ if promoteResult.Error != nil {
+ tx.Rollback()
+ return 0, promoteResult.Error
+ }
+ if promoteResult.RowsAffected == 0 {
+ tx.Rollback()
+ return 0, fmt.Errorf("候选渠道晋升状态更新失败")
+ }
+ if err := tx.Commit().Error; err != nil {
+ return 0, err
+ }
+ return channelID, nil
+}
+
+func PromoteChannelPreparation(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ channelID, err := promoteChannelPreparation(id)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ common.ApiSuccess(c, gin.H{"id": id, "channel_id": channelID})
+}
+
+func PromoteChannelPreparationsBatch(c *gin.Context) {
+ var request channelPreparationBatchRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ results := make([]channelPreparationPromoteResult, 0, len(request.Ids))
+ succeeded := false
+ for _, id := range request.Ids {
+ channelID, err := promoteChannelPreparation(id)
+ if err != nil {
+ results = append(results, channelPreparationPromoteResult{Id: id, Ok: false, Error: err.Error()})
+ continue
+ }
+ succeeded = true
+ results = append(results, channelPreparationPromoteResult{Id: id, ChannelId: channelID, Ok: true})
+ }
+ if succeeded {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ common.ApiSuccess(c, gin.H{"results": results})
+}
diff --git a/model/channel.go b/model/channel.go
index 57eb5c3ee722..913eefa3ce02 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -423,6 +423,23 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
return channel, nil
}
+func CreateChannelsWithTx(tx *gorm.DB, channels []Channel) error {
+ if len(channels) == 0 {
+ return nil
+ }
+ for _, chunk := range lo.Chunk(channels, 50) {
+ if err := tx.Create(&chunk).Error; err != nil {
+ return err
+ }
+ for _, channel_ := range chunk {
+ if err := channel_.AddAbilities(tx); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
func BatchInsertChannels(channels []Channel) error {
if len(channels) == 0 {
return nil
@@ -437,17 +454,9 @@ func BatchInsertChannels(channels []Channel) error {
}
}()
- for _, chunk := range lo.Chunk(channels, 50) {
- if err := tx.Create(&chunk).Error; err != nil {
- tx.Rollback()
- return err
- }
- for _, channel_ := range chunk {
- if err := channel_.AddAbilities(tx); err != nil {
- tx.Rollback()
- return err
- }
- }
+ if err := CreateChannelsWithTx(tx, channels); err != nil {
+ tx.Rollback()
+ return err
}
return tx.Commit().Error
}
diff --git a/model/channel_preparation.go b/model/channel_preparation.go
new file mode 100644
index 000000000000..365343787ff5
--- /dev/null
+++ b/model/channel_preparation.go
@@ -0,0 +1,285 @@
+package model
+
+import (
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "gorm.io/gorm"
+)
+
+const (
+ ChannelPreparationStatusPending = 1
+ ChannelPreparationStatusPromoted = 2
+ ChannelPreparationStatusArchived = 3
+ ChannelPreparationStatusPromoting = 4
+)
+
+type ChannelPreparation struct {
+ Id int `json:"id"`
+ Type int `json:"type" gorm:"default:0"`
+ Key string `json:"key" gorm:"not null"`
+ OpenAIOrganization *string `json:"openai_organization"`
+ TestModel *string `json:"test_model"`
+ Name string `json:"name" gorm:"index"`
+ Weight *uint `json:"weight" gorm:"default:0"`
+ CreatedTime int64 `json:"created_time" gorm:"bigint"`
+ UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
+ BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"`
+ Other string `json:"other"`
+ Balance float64 `json:"balance"`
+ Models string `json:"models"`
+ Group string `json:"group" gorm:"type:varchar(64);default:'default'"`
+ ModelMapping *string `json:"model_mapping" gorm:"type:text"`
+ StatusCodeMapping *string `json:"status_code_mapping" gorm:"type:varchar(1024);default:''"`
+ Priority *int64 `json:"priority" gorm:"bigint;default:0"`
+ AutoBan *int `json:"auto_ban" gorm:"default:1"`
+ OtherInfo string `json:"other_info"`
+ Tag *string `json:"tag" gorm:"index"`
+ Setting *string `json:"setting" gorm:"type:text"`
+ ParamOverride *string `json:"param_override" gorm:"type:text"`
+ HeaderOverride *string `json:"header_override" gorm:"type:text"`
+ Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"`
+ OtherSettings string `json:"settings" gorm:"column:settings"`
+
+ Status int `json:"status" gorm:"default:1;index"`
+ Source string `json:"source" gorm:"type:varchar(64);index"`
+ Note string `json:"note" gorm:"type:text"`
+ PromotedTime *int64 `json:"promoted_time" gorm:"bigint"`
+ PromotedChannelId *int `json:"promoted_channel_id" gorm:"index"`
+}
+
+type ChannelPreparationResponse struct {
+ Id int `json:"id"`
+ Type int `json:"type"`
+ KeyPreview string `json:"key_preview"`
+ OpenAIOrganization *string `json:"openai_organization"`
+ TestModel *string `json:"test_model"`
+ Name string `json:"name"`
+ Weight *uint `json:"weight"`
+ CreatedTime int64 `json:"created_time"`
+ UpdatedTime int64 `json:"updated_time"`
+ BaseURL *string `json:"base_url"`
+ Other string `json:"other"`
+ Balance float64 `json:"balance"`
+ Models string `json:"models"`
+ Group string `json:"group"`
+ ModelMapping *string `json:"model_mapping"`
+ StatusCodeMapping *string `json:"status_code_mapping"`
+ Priority *int64 `json:"priority"`
+ AutoBan *int `json:"auto_ban"`
+ OtherInfo string `json:"other_info"`
+ Tag *string `json:"tag"`
+ Setting *string `json:"setting"`
+ ParamOverride *string `json:"param_override"`
+ HeaderOverride *string `json:"header_override"`
+ Remark *string `json:"remark"`
+ OtherSettings string `json:"settings"`
+ Status int `json:"status"`
+ Source string `json:"source"`
+ Note string `json:"note"`
+ PromotedTime *int64 `json:"promoted_time"`
+ PromotedChannelId *int `json:"promoted_channel_id"`
+}
+
+type ChannelPreparationListOptions struct {
+ Page int
+ PageSize int
+ Keyword string
+ Group string
+ Type *int
+ Status *int
+ IDSort bool
+}
+
+type ChannelPreparationCountRow struct {
+ Value int `json:"value"`
+ Count int64 `json:"count"`
+}
+
+func (p *ChannelPreparation) NormalizeForCreate() {
+ now := common.GetTimestamp()
+ p.Id = 0
+ p.Status = ChannelPreparationStatusPending
+ p.CreatedTime = now
+ p.UpdatedTime = now
+ p.PromotedTime = nil
+ p.PromotedChannelId = nil
+ if strings.TrimSpace(p.Group) == "" {
+ p.Group = "default"
+ }
+ if p.AutoBan == nil {
+ defaultAutoBan := 1
+ p.AutoBan = &defaultAutoBan
+ }
+}
+
+func (p *ChannelPreparation) NormalizeForUpdate(existing *ChannelPreparation) {
+ p.Id = existing.Id
+ p.Status = existing.Status
+ p.CreatedTime = existing.CreatedTime
+ p.UpdatedTime = common.GetTimestamp()
+ p.PromotedTime = existing.PromotedTime
+ p.PromotedChannelId = existing.PromotedChannelId
+ if strings.TrimSpace(p.Key) == "" {
+ p.Key = existing.Key
+ }
+ if strings.TrimSpace(p.Group) == "" {
+ p.Group = "default"
+ }
+ if p.AutoBan == nil {
+ defaultAutoBan := 1
+ p.AutoBan = &defaultAutoBan
+ }
+}
+
+func (p *ChannelPreparation) KeyPreview() string {
+ key := strings.TrimSpace(p.Key)
+ if key == "" {
+ return ""
+ }
+ if len(key) <= 12 {
+ return key
+ }
+ return key[:8] + "..." + key[len(key)-4:]
+}
+
+func (p *ChannelPreparation) ToResponse() ChannelPreparationResponse {
+ return ChannelPreparationResponse{
+ Id: p.Id,
+ Type: p.Type,
+ KeyPreview: p.KeyPreview(),
+ OpenAIOrganization: p.OpenAIOrganization,
+ TestModel: p.TestModel,
+ Name: p.Name,
+ Weight: p.Weight,
+ CreatedTime: p.CreatedTime,
+ UpdatedTime: p.UpdatedTime,
+ BaseURL: p.BaseURL,
+ Other: p.Other,
+ Balance: p.Balance,
+ Models: p.Models,
+ Group: p.Group,
+ ModelMapping: p.ModelMapping,
+ StatusCodeMapping: p.StatusCodeMapping,
+ Priority: p.Priority,
+ AutoBan: p.AutoBan,
+ OtherInfo: p.OtherInfo,
+ Tag: p.Tag,
+ Setting: p.Setting,
+ ParamOverride: p.ParamOverride,
+ HeaderOverride: p.HeaderOverride,
+ Remark: p.Remark,
+ OtherSettings: p.OtherSettings,
+ Status: p.Status,
+ Source: p.Source,
+ Note: p.Note,
+ PromotedTime: p.PromotedTime,
+ PromotedChannelId: p.PromotedChannelId,
+ }
+}
+
+func ChannelPreparationResponses(preparations []ChannelPreparation) []ChannelPreparationResponse {
+ responses := make([]ChannelPreparationResponse, 0, len(preparations))
+ for _, preparation := range preparations {
+ responses = append(responses, preparation.ToResponse())
+ }
+ return responses
+}
+
+func (p *ChannelPreparation) ToChannel() *Channel {
+ group := p.Group
+ if strings.TrimSpace(group) == "" {
+ group = "default"
+ }
+ autoBan := p.AutoBan
+ if autoBan == nil {
+ defaultAutoBan := 1
+ autoBan = &defaultAutoBan
+ }
+ return &Channel{
+ Type: p.Type,
+ Key: p.Key,
+ OpenAIOrganization: p.OpenAIOrganization,
+ TestModel: p.TestModel,
+ Status: common.ChannelStatusEnabled,
+ Name: p.Name,
+ Weight: p.Weight,
+ BaseURL: p.BaseURL,
+ Other: p.Other,
+ Balance: p.Balance,
+ Models: p.Models,
+ Group: group,
+ ModelMapping: p.ModelMapping,
+ StatusCodeMapping: p.StatusCodeMapping,
+ Priority: p.Priority,
+ AutoBan: autoBan,
+ OtherInfo: p.OtherInfo,
+ Tag: p.Tag,
+ Setting: p.Setting,
+ ParamOverride: p.ParamOverride,
+ HeaderOverride: p.HeaderOverride,
+ Remark: p.Remark,
+ OtherSettings: p.OtherSettings,
+ }
+}
+
+func applyChannelPreparationFilters(db *gorm.DB, opts ChannelPreparationListOptions, includeStatus bool, includeType bool) *gorm.DB {
+ keyword := strings.TrimSpace(opts.Keyword)
+ if keyword != "" {
+ like := "%" + keyword + "%"
+ db = db.Where("(id = ? OR name LIKE ? OR "+commonKeyCol+" = ? OR source LIKE ? OR note LIKE ?)", common.String2Int(keyword), like, keyword, like, like)
+ }
+ group := strings.TrimSpace(opts.Group)
+ if group != "" {
+ db = db.Where(commonGroupCol+" LIKE ?", "%"+group+"%")
+ }
+ if includeType && opts.Type != nil {
+ db = db.Where("type = ?", *opts.Type)
+ }
+ if includeStatus && opts.Status != nil {
+ db = db.Where("status = ?", *opts.Status)
+ }
+ return db
+}
+
+func GetChannelPreparations(opts ChannelPreparationListOptions) ([]ChannelPreparation, int64, []ChannelPreparationCountRow, []ChannelPreparationCountRow, error) {
+ if opts.Page <= 0 {
+ opts.Page = 1
+ }
+ if opts.PageSize <= 0 {
+ opts.PageSize = 20
+ }
+ if opts.PageSize > 100 {
+ opts.PageSize = 100
+ }
+
+ base := applyChannelPreparationFilters(DB.Model(&ChannelPreparation{}), opts, true, true)
+ var total int64
+ if err := base.Count(&total).Error; err != nil {
+ return nil, 0, nil, nil, err
+ }
+
+ var preparations []ChannelPreparation
+ order := "created_time desc, id desc"
+ if opts.IDSort {
+ order = "id desc"
+ }
+ err := base.Order(order).Limit(opts.PageSize).Offset((opts.Page - 1) * opts.PageSize).Find(&preparations).Error
+ if err != nil {
+ return nil, 0, nil, nil, err
+ }
+
+ var statusCounts []ChannelPreparationCountRow
+ statusQuery := applyChannelPreparationFilters(DB.Model(&ChannelPreparation{}), opts, false, true)
+ if err := statusQuery.Select("status as value, count(*) as count").Group("status").Scan(&statusCounts).Error; err != nil {
+ return nil, 0, nil, nil, err
+ }
+
+ var typeCounts []ChannelPreparationCountRow
+ typeQuery := applyChannelPreparationFilters(DB.Model(&ChannelPreparation{}), opts, true, false)
+ if err := typeQuery.Select("type as value, count(*) as count").Group("type").Scan(&typeCounts).Error; err != nil {
+ return nil, 0, nil, nil, err
+ }
+
+ return preparations, total, statusCounts, typeCounts, nil
+}
diff --git a/model/main.go b/model/main.go
index d42d47c3c0c0..80e86f420c64 100644
--- a/model/main.go
+++ b/model/main.go
@@ -257,6 +257,7 @@ func migrateDB() error {
err := DB.AutoMigrate(
&Channel{},
+ &ChannelPreparation{},
&Token{},
&User{},
&PasskeyCredential{},
@@ -311,6 +312,7 @@ func migrateDBFast() error {
name string
}{
{&Channel{}, "Channel"},
+ {&ChannelPreparation{}, "ChannelPreparation"},
{&Token{}, "Token"},
{&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"},
diff --git a/router/api-router.go b/router/api-router.go
index 02837951d6d8..d008f666b5ef 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -250,6 +250,14 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels)
+ channelRoute.GET("/preparations", controller.GetChannelPreparations)
+ channelRoute.POST("/preparations", controller.AddChannelPreparation)
+ channelRoute.POST("/preparations/import", controller.ImportChannelPreparations)
+ channelRoute.POST("/preparations/batch/promote", controller.PromoteChannelPreparationsBatch)
+ channelRoute.GET("/preparations/:id", controller.GetChannelPreparation)
+ channelRoute.PUT("/preparations/:id", controller.UpdateChannelPreparation)
+ channelRoute.DELETE("/preparations/:id", controller.ArchiveChannelPreparation)
+ channelRoute.POST("/preparations/:id/promote", controller.PromoteChannelPreparation)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels)
diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx
index fe8a6dda7917..56b2a3c2f965 100644
--- a/web/classic/src/App.jsx
+++ b/web/classic/src/App.jsx
@@ -32,6 +32,7 @@ import { StatusContext } from './context/Status';
import PasswordResetForm from './components/auth/PasswordResetForm';
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
import Channel from './pages/Channel';
+import ChannelPreparation from './pages/ChannelPreparation';
import Token from './pages/Token';
import Redemption from './pages/Redemption';
import TopUp from './pages/TopUp';
@@ -139,6 +140,14 @@ function App() {
}
/>
+
+
+
+ }
+ />
{} }) => {
to: '/channel',
className: isAdmin() ? '' : 'tableHiddle',
},
+ {
+ text: t('渠道备货池'),
+ itemKey: 'channelPreparation',
+ to: '/console/channel-preparations',
+ className: isAdmin() ? '' : 'tableHiddle',
+ },
{
text: t('订阅管理'),
itemKey: 'subscription',
diff --git a/web/classic/src/components/table/channel-preparations/PreparationActions.jsx b/web/classic/src/components/table/channel-preparations/PreparationActions.jsx
new file mode 100644
index 000000000000..877049bb12df
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/PreparationActions.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { Button, Modal } from '@douyinfe/semi-ui';
+import { IconRefresh, IconDelete } from '@douyinfe/semi-icons';
+
+const PreparationActions = ({
+ t,
+ refresh,
+ selectedPreparations,
+ promoteSelected,
+ archiveSelected,
+}) => {
+ const hasSelection = selectedPreparations.length > 0;
+
+ return (
+
+ {
+ Modal.confirm({
+ title: t('确认批量晋升?'),
+ content: t('选中的候选渠道会被创建为正式渠道。'),
+ onOk: promoteSelected,
+ });
+ }}
+ >
+ {t('批量晋升')}
+
+ }
+ disabled={!hasSelection}
+ onClick={() => {
+ Modal.confirm({
+ title: t('确认批量归档?'),
+ content: t('归档后候选渠道不会被晋升。'),
+ onOk: archiveSelected,
+ });
+ }}
+ >
+ {t('批量归档')}
+
+ }
+ onClick={refresh}
+ >
+ {t('刷新')}
+
+
+ );
+};
+
+export default PreparationActions;
diff --git a/web/classic/src/components/table/channel-preparations/PreparationColumnDefs.jsx b/web/classic/src/components/table/channel-preparations/PreparationColumnDefs.jsx
new file mode 100644
index 000000000000..be27919e4a03
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/PreparationColumnDefs.jsx
@@ -0,0 +1,180 @@
+import React from 'react';
+import { Button, Modal, Space, Tag, Typography } from '@douyinfe/semi-ui';
+import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
+import {
+ PREPARATION_STATUS,
+ PREPARATION_STATUS_LABELS,
+} from '../../../hooks/channels/useChannelPreparationsData';
+
+const statusColor = {
+ [PREPARATION_STATUS.PENDING]: 'blue',
+ [PREPARATION_STATUS.PROMOTED]: 'green',
+ [PREPARATION_STATUS.ARCHIVED]: 'grey',
+};
+
+const formatTime = (timestamp) => {
+ if (!timestamp) return '-';
+ return new Date(timestamp * 1000).toLocaleString();
+};
+
+const getChannelLabel = (type) => {
+ return CHANNEL_OPTIONS.find((item) => item.value === type)?.label || type;
+};
+
+const getModelsPreview = (models) => {
+ if (!models) return '-';
+ const list = String(models)
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ if (list.length === 0) return '-';
+ if (list.length <= 2) return list.join(', ');
+ return `${list.slice(0, 2).join(', ')} +${list.length - 2}`;
+};
+
+export const getPreparationColumns = ({
+ t,
+ openEdit,
+ promotePreparation,
+ archivePreparation,
+}) => [
+ {
+ title: 'ID',
+ dataIndex: 'id',
+ key: 'id',
+ width: 80,
+ fixed: true,
+ },
+ {
+ title: t('名称'),
+ dataIndex: 'name',
+ key: 'name',
+ width: 180,
+ render: (text) => (
+ {text}
+ ),
+ },
+ {
+ title: t('渠道类型'),
+ dataIndex: 'type',
+ key: 'type',
+ width: 160,
+ render: (value) => getChannelLabel(value),
+ },
+ {
+ title: t('状态'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (value) => (
+
+ {t(PREPARATION_STATUS_LABELS[value] || '未知')}
+
+ ),
+ },
+ {
+ title: t('分组'),
+ dataIndex: 'group',
+ key: 'group',
+ width: 140,
+ },
+ {
+ title: 'Key',
+ dataIndex: 'key_preview',
+ key: 'key_preview',
+ width: 160,
+ render: (value) => value || '-',
+ },
+ {
+ title: t('模型'),
+ dataIndex: 'models',
+ key: 'models',
+ width: 220,
+ render: getModelsPreview,
+ },
+ {
+ title: t('余额'),
+ dataIndex: 'balance',
+ key: 'balance',
+ width: 100,
+ render: (value) => value ?? 0,
+ },
+ {
+ title: t('优先级'),
+ dataIndex: 'priority',
+ key: 'priority',
+ width: 90,
+ render: (value) => value ?? 0,
+ },
+ {
+ title: t('权重'),
+ dataIndex: 'weight',
+ key: 'weight',
+ width: 90,
+ render: (value) => value ?? 0,
+ },
+ {
+ title: t('晋升渠道'),
+ dataIndex: 'promoted_channel_id',
+ key: 'promoted_channel_id',
+ width: 110,
+ render: (value) => value || '-',
+ },
+ {
+ title: t('创建时间'),
+ dataIndex: 'created_time',
+ key: 'created_time',
+ width: 180,
+ render: formatTime,
+ },
+ {
+ title: t('操作'),
+ key: 'operate',
+ fixed: 'right',
+ width: 210,
+ render: (_, record) => {
+ const pending = record.status === PREPARATION_STATUS.PENDING;
+ return (
+
+ {
+ Modal.confirm({
+ title: t('确认晋升?'),
+ content: t('该候选渠道会被创建为正式渠道。'),
+ onOk: () => promotePreparation(record),
+ });
+ }}
+ >
+ {t('晋升')}
+
+ openEdit(record)}
+ >
+ {t('编辑')}
+
+ {
+ Modal.confirm({
+ title: t('确认归档?'),
+ content: t('归档后候选渠道不会被晋升。'),
+ onOk: () => archivePreparation(record),
+ });
+ }}
+ >
+ {t('归档')}
+
+
+ );
+ },
+ },
+];
diff --git a/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx b/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
new file mode 100644
index 000000000000..d047ebd2c1bb
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { Button, Input, Select } from '@douyinfe/semi-ui';
+import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
+import {
+ PREPARATION_STATUS,
+ PREPARATION_STATUS_LABELS,
+} from '../../../hooks/channels/useChannelPreparationsData';
+
+const PreparationFilters = ({
+ t,
+ keyword,
+ setKeyword,
+ group,
+ setGroup,
+ type,
+ setType,
+ status,
+ setStatus,
+ handleSearch,
+}) => {
+ return (
+
+
+
+
+
+
+ {t('搜索')}
+
+
+ );
+};
+
+export default PreparationFilters;
diff --git a/web/classic/src/components/table/channel-preparations/PreparationTable.jsx b/web/classic/src/components/table/channel-preparations/PreparationTable.jsx
new file mode 100644
index 000000000000..83202bd9dbc4
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/PreparationTable.jsx
@@ -0,0 +1,85 @@
+import React, { useMemo } from 'react';
+import { Empty } from '@douyinfe/semi-ui';
+import {
+ IllustrationNoResult,
+ IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import CardTable from '../../common/ui/CardTable';
+import { PREPARATION_STATUS } from '../../../hooks/channels/useChannelPreparationsData';
+import { getPreparationColumns } from './PreparationColumnDefs';
+
+const PreparationTable = ({
+ t,
+ preparations,
+ loading,
+ activePage,
+ pageSize,
+ total,
+ handlePageChange,
+ handlePageSizeChange,
+ selectedPreparationKeys,
+ setSelectedPreparationKeys,
+ setSelectedPreparations,
+ openEdit,
+ promotePreparation,
+ archivePreparation,
+}) => {
+ const columns = useMemo(
+ () =>
+ getPreparationColumns({
+ t,
+ openEdit,
+ promotePreparation,
+ archivePreparation,
+ }),
+ [t, openEdit, promotePreparation, archivePreparation],
+ );
+
+ return (
+ ({
+ disabled: record.status !== PREPARATION_STATUS.PENDING,
+ }),
+ onChange: (selectedRowKeys, selectedRows) => {
+ setSelectedPreparationKeys(selectedRowKeys);
+ setSelectedPreparations(
+ selectedRows.filter(
+ (item) => item.status === PREPARATION_STATUS.PENDING,
+ ),
+ );
+ },
+ }}
+ empty={
+ }
+ darkModeImage={
+
+ }
+ description={t('搜索无结果')}
+ style={{ padding: 30 }}
+ />
+ }
+ className='rounded-xl overflow-hidden'
+ size='middle'
+ loading={loading}
+ />
+ );
+};
+
+export default PreparationTable;
diff --git a/web/classic/src/components/table/channel-preparations/index.jsx b/web/classic/src/components/table/channel-preparations/index.jsx
new file mode 100644
index 000000000000..dc898f9511b9
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/index.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import { Button, Typography } from '@douyinfe/semi-ui';
+import { IconPlus, IconUpload } from '@douyinfe/semi-icons';
+import CardPro from '../../common/ui/CardPro';
+import { createCardProPagination } from '../../../helpers/utils';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+import { useChannelPreparationsData } from '../../../hooks/channels/useChannelPreparationsData';
+import PreparationActions from './PreparationActions';
+import PreparationFilters from './PreparationFilters';
+import PreparationTable from './PreparationTable';
+import EditPreparationModal from './modals/EditPreparationModal';
+import ImportPreparationModal from './modals/ImportPreparationModal';
+
+const ChannelPreparationsPage = () => {
+ const data = useChannelPreparationsData();
+ const isMobile = useIsMobile();
+
+ return (
+ <>
+
+ data.setShowImport(false)}
+ onSubmit={data.importPreparations}
+ />
+
+
+
+ {data.t('渠道备货池')}
+
+
+ {data.t(
+ '候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。',
+ )}
+
+
+
+ }
+ onClick={data.openCreate}
+ className='w-full sm:w-auto'
+ >
+ {data.t('添加候选渠道')}
+
+ }
+ onClick={() => data.setShowImport(true)}
+ className='w-full sm:w-auto'
+ >
+ {data.t('导入候选渠道')}
+
+
+
+ }
+ actionsArea={}
+ searchArea={}
+ paginationArea={createCardProPagination({
+ currentPage: data.activePage,
+ pageSize: data.pageSize,
+ total: data.total,
+ onPageChange: data.handlePageChange,
+ onPageSizeChange: data.handlePageSizeChange,
+ isMobile,
+ t: data.t,
+ })}
+ t={data.t}
+ >
+
+
+ >
+ );
+};
+
+export default ChannelPreparationsPage;
diff --git a/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx b/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
new file mode 100644
index 000000000000..d6b33616a0e1
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
@@ -0,0 +1,250 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ Button,
+ Input,
+ InputNumber,
+ Modal,
+ Select,
+ TextArea,
+} from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import { CHANNEL_OPTIONS } from '../../../../constants/channel.constants';
+import {
+ getChannelModels,
+ loadChannelModels,
+ API,
+ showError,
+} from '../../../../helpers';
+
+const DEFAULT_TYPE = 14;
+
+const emptyForm = {
+ type: DEFAULT_TYPE,
+ name: '',
+ key: '',
+ base_url: '',
+ models: '',
+ group: 'default',
+ balance: 0,
+ priority: 0,
+ weight: 0,
+ tag: '',
+ remark: '',
+ note: '',
+};
+
+const getModelText = (type) => getChannelModels(type).join(',');
+
+const EditPreparationModal = ({ visible, preparation, onCancel, onSubmit }) => {
+ const { t } = useTranslation();
+ const [form, setForm] = useState(emptyForm);
+ const [submitting, setSubmitting] = useState(false);
+ const [groupOptions, setGroupOptions] = useState([
+ { label: 'default', value: 'default' },
+ ]);
+
+ const isEdit = Boolean(preparation?.id);
+
+ useEffect(() => {
+ if (!visible) return;
+ loadChannelModels().catch(() => {});
+ API.get('/api/group/')
+ .then((res) => {
+ if (res.data.success && Array.isArray(res.data.data)) {
+ const options = res.data.data.map((item) => ({
+ label: item,
+ value: item,
+ }));
+ setGroupOptions(
+ options.length > 0
+ ? options
+ : [{ label: 'default', value: 'default' }],
+ );
+ }
+ })
+ .catch(() => {});
+ }, [visible]);
+
+ useEffect(() => {
+ if (!visible) return;
+ if (preparation) {
+ setForm({
+ ...emptyForm,
+ ...preparation,
+ key: '',
+ base_url: preparation.base_url || '',
+ tag: preparation.tag || '',
+ remark: preparation.remark || '',
+ note: preparation.note || '',
+ priority: preparation.priority ?? 0,
+ weight: preparation.weight ?? 0,
+ group: preparation.group || 'default',
+ });
+ } else {
+ setForm({ ...emptyForm });
+ }
+ }, [visible, preparation]);
+
+ const typeOptions = useMemo(
+ () =>
+ CHANNEL_OPTIONS.map((option) => ({
+ label: option.label,
+ value: option.value,
+ })),
+ [],
+ );
+
+ const update = (key, value) => setForm((prev) => ({ ...prev, [key]: value }));
+
+ const handleTypeChange = (value) => {
+ const models = getModelText(value);
+ setForm((prev) => ({
+ ...prev,
+ type: value,
+ models: prev.models || models,
+ }));
+ };
+
+ const handleSubmit = async () => {
+ if (!form.name.trim()) {
+ showError(t('名称不能为空'));
+ return;
+ }
+ if (!isEdit && !form.key.trim()) {
+ showError(t('Key 不能为空'));
+ return;
+ }
+ setSubmitting(true);
+ try {
+ const payload = {
+ ...form,
+ id: preparation?.id,
+ type: Number(form.type),
+ balance: Number(form.balance) || 0,
+ priority: Number(form.priority) || 0,
+ weight: Number(form.weight) || 0,
+ base_url: form.base_url ? form.base_url : undefined,
+ tag: form.tag ? form.tag : undefined,
+ remark: form.remark ? form.remark : undefined,
+ };
+ await onSubmit(payload);
+ } catch (error) {
+ showError(error.message || t('保存失败'));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+ {t('取消')}
+
+ {t('保存')}
+
+
+ }
+ style={{ width: 720 }}
+ >
+
+
+
+
{t('名称')}
+
update('name', value)}
+ />
+
+
+
+
Base URL
+
update('base_url', value)}
+ />
+
+
+
{t('分组')}
+
+
+
+
{t('余额')}
+
update('balance', value ?? 0)}
+ style={{ width: '100%' }}
+ />
+
+
+
{t('优先级')}
+
update('priority', value ?? 0)}
+ style={{ width: '100%' }}
+ />
+
+
+
{t('权重')}
+
update('weight', value ?? 0)}
+ style={{ width: '100%' }}
+ />
+
+
+
Tag
+
update('tag', value)} />
+
+
+
+
+ );
+};
+
+export default EditPreparationModal;
diff --git a/web/classic/src/components/table/channel-preparations/modals/ImportPreparationModal.jsx b/web/classic/src/components/table/channel-preparations/modals/ImportPreparationModal.jsx
new file mode 100644
index 000000000000..c995fbe934d9
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/modals/ImportPreparationModal.jsx
@@ -0,0 +1,266 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import {
+ Button,
+ Input,
+ InputNumber,
+ Modal,
+ Progress,
+ Select,
+ Table,
+ TextArea,
+ Typography,
+} from '@douyinfe/semi-ui';
+import { useTranslation } from 'react-i18next';
+import {
+ API,
+ getChannelModels,
+ loadChannelModels,
+ showError,
+} from '../../../../helpers';
+
+const DEFAULT_GROUP = 'default';
+const ANTHROPIC_CHANNEL_TYPE = 14;
+
+const generateTimestamp = () => {
+ const now = new Date();
+ const pad = (value) => String(value).padStart(2, '0');
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`;
+};
+
+const generateChannelName = (balance, suffix, timestamp) => {
+ return `${timestamp}-${balance}-${suffix}`;
+};
+
+const parseBatchInput = (text, suffix, timestamp) => {
+ const entries = [];
+ const errors = [];
+ text
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .forEach((line, index) => {
+ const parts = line
+ .split(/\t+|\s{2,}/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+ if (parts.length < 2) {
+ errors.push({ line: index + 1, message: '格式应为:余额Key' });
+ return;
+ }
+ const balance = Number(parts[0]);
+ const key = parts.slice(1).join('').trim();
+ if (!key) {
+ errors.push({ line: index + 1, message: 'Key 不能为空' });
+ return;
+ }
+ entries.push({
+ name: generateChannelName(
+ Number.isFinite(balance) ? balance : 0,
+ suffix,
+ timestamp,
+ ),
+ balance: Number.isFinite(balance) ? balance : 0,
+ key,
+ });
+ });
+ return { entries, errors };
+};
+
+const ImportPreparationModal = ({ visible, onCancel, onSubmit }) => {
+ const { t } = useTranslation();
+ const [inputText, setInputText] = useState('');
+ const [nameSuffix, setNameSuffix] = useState('');
+ const [models, setModels] = useState('');
+ const [group, setGroup] = useState(DEFAULT_GROUP);
+ const [priority, setPriority] = useState(0);
+ const [weight, setWeight] = useState(0);
+ const [groupOptions, setGroupOptions] = useState([
+ { label: DEFAULT_GROUP, value: DEFAULT_GROUP },
+ ]);
+ const [importing, setImporting] = useState(false);
+ const [results, setResults] = useState([]);
+ const timestamp = useMemo(() => generateTimestamp(), [visible]);
+
+ useEffect(() => {
+ if (!visible) return;
+ loadChannelModels().catch(() => {});
+ API.get('/api/group/')
+ .then((res) => {
+ const groups = Array.isArray(res?.data?.data) ? res.data.data : [];
+ const uniqueGroups = Array.from(
+ new Set([DEFAULT_GROUP, ...groups].filter(Boolean)),
+ );
+ setGroupOptions(
+ uniqueGroups.map((item) => ({ label: item, value: item })),
+ );
+ })
+ .catch((error) => showError(error.message));
+ }, [visible]);
+
+ const defaultModels = useMemo(
+ () => getChannelModels(ANTHROPIC_CHANNEL_TYPE).join(','),
+ [],
+ );
+ const parsed = useMemo(
+ () => parseBatchInput(inputText, nameSuffix, timestamp),
+ [inputText, nameSuffix, timestamp],
+ );
+ const progress =
+ parsed.entries.length === 0
+ ? 0
+ : Math.round(
+ (results.filter((item) => item.ok).length / parsed.entries.length) *
+ 100,
+ );
+
+ const reset = () => {
+ setInputText('');
+ setNameSuffix('');
+ setModels('');
+ setGroup(DEFAULT_GROUP);
+ setPriority(0);
+ setWeight(0);
+ setResults([]);
+ setImporting(false);
+ };
+
+ const handleCancel = () => {
+ reset();
+ onCancel();
+ };
+
+ const handleImport = async () => {
+ if (parsed.entries.length === 0) return;
+ setImporting(true);
+ setResults([]);
+ try {
+ const finalModels = models.trim();
+ const items = parsed.entries.map((entry) => ({
+ name: entry.name,
+ type: ANTHROPIC_CHANNEL_TYPE,
+ key: entry.key,
+ models: finalModels,
+ group,
+ balance: entry.balance,
+ priority: Number(priority) || 0,
+ weight: Number(weight) || 0,
+ auto_ban: 1,
+ source: 'batch_import',
+ }));
+ const importResults = await onSubmit(items);
+ setResults(importResults);
+ } catch (error) {
+ showError(error.message || t('导入失败'));
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const previewColumns = [
+ { title: t('名称'), dataIndex: 'name', key: 'name' },
+ { title: t('余额'), dataIndex: 'balance', key: 'balance', width: 100 },
+ {
+ title: 'Key',
+ dataIndex: 'key',
+ key: 'key',
+ render: (value) => `${value.slice(0, 8)}...${value.slice(-4)}`,
+ },
+ ];
+
+ return (
+
+ {t('关闭')}
+ 0}
+ onClick={handleImport}
+ >
+ {t('导入到备货池')}
+
+
+ }
+ style={{ width: 860 }}
+ >
+
+
+ {t('每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。')}
+
+
+
+
+
+
{t('分组')}
+
+
+
{t('优先级')}
+
setPriority(value ?? 0)}
+ style={{ width: '100%' }}
+ />
+
+
+
{t('权重')}
+
setWeight(value ?? 0)}
+ style={{ width: '100%' }}
+ />
+
+
+
+ {parsed.errors.length > 0 ? (
+
+ {parsed.errors
+ .map((error) => `#${error.line}: ${error.message}`)
+ .join(';')}
+
+ ) : null}
+
+
+ );
+};
+
+export default ImportPreparationModal;
diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx
index 46c95b236831..96bea8236a4d 100644
--- a/web/classic/src/helpers/render.jsx
+++ b/web/classic/src/helpers/render.jsx
@@ -138,6 +138,7 @@ export function getLucideIcon(key, selected = false) {
case 'topup':
return ;
case 'channel':
+ case 'channelPreparation':
return ;
case 'redemption':
return ;
diff --git a/web/classic/src/hooks/channels/useChannelPreparationsData.jsx b/web/classic/src/hooks/channels/useChannelPreparationsData.jsx
new file mode 100644
index 000000000000..8d59e59f5557
--- /dev/null
+++ b/web/classic/src/hooks/channels/useChannelPreparationsData.jsx
@@ -0,0 +1,288 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { API, showError, showSuccess, showInfo } from '../../helpers';
+
+export const PREPARATION_STATUS = {
+ PENDING: 1,
+ PROMOTED: 2,
+ ARCHIVED: 3,
+};
+
+export const PREPARATION_STATUS_LABELS = {
+ [PREPARATION_STATUS.PENDING]: '待晋升',
+ [PREPARATION_STATUS.PROMOTED]: '已晋升',
+ [PREPARATION_STATUS.ARCHIVED]: '已归档',
+};
+
+const DEFAULT_PAGE_SIZE = 20;
+
+export function useChannelPreparationsData() {
+ const { t } = useTranslation();
+ const [preparations, setPreparations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [activePage, setActivePage] = useState(1);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [total, setTotal] = useState(0);
+ const [keyword, setKeyword] = useState('');
+ const [group, setGroup] = useState('');
+ const [type, setType] = useState(undefined);
+ const [status, setStatus] = useState(undefined);
+ const [selectedPreparationKeys, setSelectedPreparationKeys] = useState([]);
+ const [selectedPreparations, setSelectedPreparations] = useState([]);
+ const [showEdit, setShowEdit] = useState(false);
+ const [showImport, setShowImport] = useState(false);
+ const [editingPreparation, setEditingPreparation] = useState(null);
+
+ const loadPreparations = useCallback(
+ async (page = activePage, size = pageSize) => {
+ setLoading(true);
+ try {
+ const params = {
+ p: page,
+ page_size: size,
+ keyword,
+ group,
+ };
+ if (type !== undefined && type !== null && type !== '')
+ params.type = type;
+ if (status !== undefined && status !== null && status !== '')
+ params.status = status;
+ const res = await API.get('/api/channel/preparations', { params });
+ const { success, data, message } = res.data;
+ if (!success) {
+ showError(message || t('加载失败'));
+ return;
+ }
+ setPreparations(data?.items || []);
+ setSelectedPreparationKeys([]);
+ setSelectedPreparations([]);
+ setTotal(data?.total || 0);
+ setActivePage(data?.page || page);
+ setPageSize(data?.page_size || size);
+ } catch (error) {
+ showError(error.message || t('加载失败'));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [activePage, pageSize, keyword, group, type, status, t],
+ );
+
+ const refresh = useCallback(
+ () => loadPreparations(activePage, pageSize),
+ [loadPreparations, activePage, pageSize],
+ );
+
+ useEffect(() => {
+ loadPreparations(1, pageSize);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const handleSearch = useCallback(() => {
+ setActivePage(1);
+ loadPreparations(1, pageSize);
+ }, [loadPreparations, pageSize]);
+
+ const handlePageChange = useCallback(
+ (page) => {
+ setActivePage(page);
+ loadPreparations(page, pageSize);
+ },
+ [loadPreparations, pageSize],
+ );
+
+ const handlePageSizeChange = useCallback(
+ (size) => {
+ setPageSize(size);
+ setActivePage(1);
+ loadPreparations(1, size);
+ },
+ [loadPreparations],
+ );
+
+ const openCreate = useCallback(() => {
+ setEditingPreparation(null);
+ setShowEdit(true);
+ }, []);
+
+ const openEdit = useCallback((preparation) => {
+ setEditingPreparation(preparation);
+ setShowEdit(true);
+ }, []);
+
+ const closeEdit = useCallback(() => {
+ setShowEdit(false);
+ setEditingPreparation(null);
+ }, []);
+
+ const savePreparation = useCallback(
+ async (payload) => {
+ const isEdit = Boolean(payload.id);
+ const res = isEdit
+ ? await API.put(`/api/channel/preparations/${payload.id}`, payload)
+ : await API.post('/api/channel/preparations', payload);
+ if (!res.data.success) {
+ throw new Error(res.data.message || t('保存失败'));
+ }
+ showSuccess(isEdit ? t('候选渠道更新成功') : t('候选渠道创建成功'));
+ closeEdit();
+ refresh();
+ return res.data.data;
+ },
+ [closeEdit, refresh, t],
+ );
+
+ const importPreparations = useCallback(
+ async (items) => {
+ const res = await API.post('/api/channel/preparations/import', { items });
+ if (!res.data.success) {
+ throw new Error(res.data.message || t('导入失败'));
+ }
+ const results = res.data.data?.results || [];
+ const successCount = results.filter((item) => item.ok).length;
+ showSuccess(t('导入完成:{{count}} 条成功', { count: successCount }));
+ refresh();
+ return results;
+ },
+ [refresh, t],
+ );
+
+ const promotePreparation = useCallback(
+ async (preparation) => {
+ const res = await API.post(
+ `/api/channel/preparations/${preparation.id}/promote`,
+ );
+ if (!res.data.success) {
+ showError(res.data.message || t('晋升失败'));
+ return false;
+ }
+ showSuccess(t('候选渠道已晋升为正式渠道'));
+ refresh();
+ return true;
+ },
+ [refresh, t],
+ );
+
+ const promoteSelected = useCallback(async () => {
+ const ids = selectedPreparations.map((item) => item.id);
+ if (ids.length === 0) {
+ showInfo(t('请先选择候选渠道'));
+ return;
+ }
+ const res = await API.post('/api/channel/preparations/batch/promote', {
+ ids,
+ });
+ if (!res.data.success) {
+ showError(res.data.message || t('批量晋升失败'));
+ return;
+ }
+ const results = res.data.data?.results || [];
+ const successCount = results.filter((item) => item.ok).length;
+ showSuccess(t('批量晋升完成:{{count}} 条成功', { count: successCount }));
+ setSelectedPreparationKeys([]);
+ setSelectedPreparations([]);
+ refresh();
+ }, [selectedPreparations, refresh, t]);
+
+ const archivePreparation = useCallback(
+ async (preparation) => {
+ const res = await API.delete(
+ `/api/channel/preparations/${preparation.id}`,
+ );
+ if (!res.data.success) {
+ showError(res.data.message || t('归档失败'));
+ return false;
+ }
+ showSuccess(t('候选渠道已归档'));
+ refresh();
+ return true;
+ },
+ [refresh, t],
+ );
+
+ const archiveSelected = useCallback(async () => {
+ if (selectedPreparations.length === 0) {
+ showInfo(t('请先选择候选渠道'));
+ return;
+ }
+ let successCount = 0;
+ for (const item of selectedPreparations) {
+ const res = await API.delete(`/api/channel/preparations/${item.id}`);
+ if (res.data.success) successCount += 1;
+ }
+ showSuccess(t('批量归档完成:{{count}} 条成功', { count: successCount }));
+ setSelectedPreparationKeys([]);
+ setSelectedPreparations([]);
+ refresh();
+ }, [selectedPreparations, refresh, t]);
+
+ return useMemo(
+ () => ({
+ t,
+ preparations,
+ loading,
+ activePage,
+ pageSize,
+ total,
+ keyword,
+ setKeyword,
+ group,
+ setGroup,
+ type,
+ setType,
+ status,
+ setStatus,
+ selectedPreparationKeys,
+ setSelectedPreparationKeys,
+ selectedPreparations,
+ setSelectedPreparations,
+ showEdit,
+ showImport,
+ setShowImport,
+ editingPreparation,
+ refresh,
+ handleSearch,
+ handlePageChange,
+ handlePageSizeChange,
+ openCreate,
+ openEdit,
+ closeEdit,
+ savePreparation,
+ importPreparations,
+ promotePreparation,
+ promoteSelected,
+ archivePreparation,
+ archiveSelected,
+ }),
+ [
+ t,
+ preparations,
+ loading,
+ activePage,
+ pageSize,
+ total,
+ keyword,
+ group,
+ type,
+ status,
+ selectedPreparationKeys,
+ selectedPreparations,
+ showEdit,
+ showImport,
+ editingPreparation,
+ refresh,
+ handleSearch,
+ handlePageChange,
+ handlePageSizeChange,
+ openCreate,
+ openEdit,
+ closeEdit,
+ savePreparation,
+ importPreparations,
+ promotePreparation,
+ promoteSelected,
+ archivePreparation,
+ archiveSelected,
+ ],
+ );
+}
diff --git a/web/classic/src/hooks/common/useSidebar.js b/web/classic/src/hooks/common/useSidebar.js
index cd74ada20280..801a9b7c059f 100644
--- a/web/classic/src/hooks/common/useSidebar.js
+++ b/web/classic/src/hooks/common/useSidebar.js
@@ -47,6 +47,7 @@ export const DEFAULT_ADMIN_CONFIG = {
admin: {
enabled: true,
channel: true,
+ channelPreparation: true,
models: true,
deployment: true,
redemption: true,
diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json
index 400613be9185..688f2e4e5bdc 100644
--- a/web/classic/src/i18n/locales/en.json
+++ b/web/classic/src/i18n/locales/en.json
@@ -3832,5 +3832,47 @@
"已用额度已清空": "Used quota cleared",
"清空已用额度失败": "Failed to clear used quota",
"已用额度已为 0": "Used quota is already 0"
- }
+ },
+ "渠道备货池": "Channel Prep Pool",
+ "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。": "Candidate channels stay in the prep pool, do not serve live traffic, and become live channels only after promotion.",
+ "添加候选渠道": "Add Candidate Channel",
+ "编辑候选渠道": "Edit Candidate Channel",
+ "导入候选渠道": "Import Candidate Channels",
+ "批量晋升": "Batch Promote",
+ "批量归档": "Batch Archive",
+ "确认批量晋升?": "Confirm batch promotion?",
+ "选中的候选渠道会被创建为正式渠道。": "Selected candidate channels will be created as live channels.",
+ "确认批量归档?": "Confirm batch archive?",
+ "归档后候选渠道不会被晋升。": "Archived candidate channels cannot be promoted.",
+ "搜索名称 / Key / 备注": "Search name / Key / note",
+ "渠道类型": "Channel Type",
+ "状态": "Status",
+ "待晋升": "Pending",
+ "已晋升": "Promoted",
+ "已归档": "Archived",
+ "晋升渠道": "Promoted Channel",
+ "确认晋升?": "Confirm promotion?",
+ "该候选渠道会被创建为正式渠道。": "This candidate channel will be created as a live channel.",
+ "晋升": "Promote",
+ "归档": "Archive",
+ "候选渠道更新成功": "Candidate channel updated",
+ "候选渠道创建成功": "Candidate channel created",
+ "导入完成:{{count}} 条成功": "Import complete: {{count}} succeeded",
+ "候选渠道已晋升为正式渠道": "Candidate channel promoted to live channel",
+ "批量晋升完成:{{count}} 条成功": "Batch promotion complete: {{count}} succeeded",
+ "请先选择候选渠道": "Please select candidate channels first",
+ "批量归档完成:{{count}} 条成功": "Batch archive complete: {{count}} succeeded",
+ "候选渠道已归档": "Candidate channel archived",
+ "加载失败": "Failed to load",
+ "保存失败": "Failed to save",
+ "导入失败": "Import failed",
+ "批量晋升失败": "Batch promotion failed",
+ "归档失败": "Archive failed",
+ "名称不能为空": "Name cannot be empty",
+ "Key 不能为空": "Key cannot be empty",
+ "留空则保留原 Key": "Leave empty to keep the existing key",
+ "导入到备货池": "Import to Prep Pool",
+ "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。": "Each line: balanceKey. Imported records enter the prep pool only and will not create live channels.",
+ "名称后缀": "Name Suffix",
+ "不填则使用 Claude 默认模型": "Leave empty to use the default Claude models"
}
diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json
index e96dbb93b035..7b4f14870049 100644
--- a/web/classic/src/i18n/locales/zh-CN.json
+++ b/web/classic/src/i18n/locales/zh-CN.json
@@ -3815,5 +3815,47 @@
"已用额度已清空": "已用额度已清空",
"清空已用额度失败": "清空已用额度失败",
"已用额度已为 0": "已用额度已为 0"
- }
+ },
+ "渠道备货池": "渠道备货池",
+ "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。": "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。",
+ "添加候选渠道": "添加候选渠道",
+ "编辑候选渠道": "编辑候选渠道",
+ "导入候选渠道": "导入候选渠道",
+ "批量晋升": "批量晋升",
+ "批量归档": "批量归档",
+ "确认批量晋升?": "确认批量晋升?",
+ "选中的候选渠道会被创建为正式渠道。": "选中的候选渠道会被创建为正式渠道。",
+ "确认批量归档?": "确认批量归档?",
+ "归档后候选渠道不会被晋升。": "归档后候选渠道不会被晋升。",
+ "搜索名称 / Key / 备注": "搜索名称 / Key / 备注",
+ "渠道类型": "渠道类型",
+ "状态": "状态",
+ "待晋升": "待晋升",
+ "已晋升": "已晋升",
+ "已归档": "已归档",
+ "晋升渠道": "晋升渠道",
+ "确认晋升?": "确认晋升?",
+ "该候选渠道会被创建为正式渠道。": "该候选渠道会被创建为正式渠道。",
+ "晋升": "晋升",
+ "归档": "归档",
+ "候选渠道更新成功": "候选渠道更新成功",
+ "候选渠道创建成功": "候选渠道创建成功",
+ "导入完成:{{count}} 条成功": "导入完成:{{count}} 条成功",
+ "候选渠道已晋升为正式渠道": "候选渠道已晋升为正式渠道",
+ "批量晋升完成:{{count}} 条成功": "批量晋升完成:{{count}} 条成功",
+ "请先选择候选渠道": "请先选择候选渠道",
+ "批量归档完成:{{count}} 条成功": "批量归档完成:{{count}} 条成功",
+ "候选渠道已归档": "候选渠道已归档",
+ "加载失败": "加载失败",
+ "保存失败": "保存失败",
+ "导入失败": "导入失败",
+ "批量晋升失败": "批量晋升失败",
+ "归档失败": "归档失败",
+ "名称不能为空": "名称不能为空",
+ "Key 不能为空": "Key 不能为空",
+ "留空则保留原 Key": "留空则保留原 Key",
+ "导入到备货池": "导入到备货池",
+ "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。": "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。",
+ "名称后缀": "名称后缀",
+ "不填则使用 Claude 默认模型": "不填则使用 Claude 默认模型"
}
diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json
index bea0820e580f..d2b53f6d42fc 100644
--- a/web/classic/src/i18n/locales/zh-TW.json
+++ b/web/classic/src/i18n/locales/zh-TW.json
@@ -3679,5 +3679,47 @@
"已用额度已清空": "已用額度已清空",
"清空已用额度失败": "清空已用額度失敗",
"已用额度已为 0": "已用額度已為 0"
- }
+ },
+ "渠道备货池": "渠道備貨池",
+ "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。": "候選渠道只保存在備貨池,不參與真實渠道調用,晉升後才會建立正式渠道。",
+ "添加候选渠道": "新增候選渠道",
+ "编辑候选渠道": "編輯候選渠道",
+ "导入候选渠道": "匯入候選渠道",
+ "批量晋升": "批次晉升",
+ "批量归档": "批次歸檔",
+ "确认批量晋升?": "確認批次晉升?",
+ "选中的候选渠道会被创建为正式渠道。": "選中的候選渠道會被建立為正式渠道。",
+ "确认批量归档?": "確認批次歸檔?",
+ "归档后候选渠道不会被晋升。": "歸檔後候選渠道不會被晉升。",
+ "搜索名称 / Key / 备注": "搜尋名稱 / Key / 備註",
+ "渠道类型": "渠道類型",
+ "状态": "狀態",
+ "待晋升": "待晉升",
+ "已晋升": "已晉升",
+ "已归档": "已歸檔",
+ "晋升渠道": "晉升渠道",
+ "确认晋升?": "確認晉升?",
+ "该候选渠道会被创建为正式渠道。": "該候選渠道會被建立為正式渠道。",
+ "晋升": "晉升",
+ "归档": "歸檔",
+ "候选渠道更新成功": "候選渠道更新成功",
+ "候选渠道创建成功": "候選渠道建立成功",
+ "导入完成:{{count}} 条成功": "匯入完成:{{count}} 條成功",
+ "候选渠道已晋升为正式渠道": "候選渠道已晉升為正式渠道",
+ "批量晋升完成:{{count}} 条成功": "批次晉升完成:{{count}} 條成功",
+ "请先选择候选渠道": "請先選擇候選渠道",
+ "批量归档完成:{{count}} 条成功": "批次歸檔完成:{{count}} 條成功",
+ "候选渠道已归档": "候選渠道已歸檔",
+ "加载失败": "載入失敗",
+ "保存失败": "儲存失敗",
+ "导入失败": "匯入失敗",
+ "批量晋升失败": "批次晉升失敗",
+ "归档失败": "歸檔失敗",
+ "名称不能为空": "名稱不能為空",
+ "Key 不能为空": "Key 不能為空",
+ "留空则保留原 Key": "留空則保留原 Key",
+ "导入到备货池": "匯入到備貨池",
+ "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。": "每行格式:餘額Key。匯入後只進入備貨池,不會建立正式渠道。",
+ "名称后缀": "名稱後綴",
+ "不填则使用 Claude 默认模型": "不填則使用 Claude 預設模型"
}
diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json
index 1e340e8f6427..52f2e6669aaa 100644
--- a/web/classic/src/i18n/locales/zh.json
+++ b/web/classic/src/i18n/locales/zh.json
@@ -2632,5 +2632,47 @@
"已用额度已清空": "已用额度已清空",
"清空已用额度失败": "清空已用额度失败",
"已用额度已为 0": "已用额度已为 0"
- }
+ },
+ "渠道备货池": "渠道备货池",
+ "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。": "候选渠道只保存在备货池,不参与真实渠道调用,晋升后才会创建正式渠道。",
+ "添加候选渠道": "添加候选渠道",
+ "编辑候选渠道": "编辑候选渠道",
+ "导入候选渠道": "导入候选渠道",
+ "批量晋升": "批量晋升",
+ "批量归档": "批量归档",
+ "确认批量晋升?": "确认批量晋升?",
+ "选中的候选渠道会被创建为正式渠道。": "选中的候选渠道会被创建为正式渠道。",
+ "确认批量归档?": "确认批量归档?",
+ "归档后候选渠道不会被晋升。": "归档后候选渠道不会被晋升。",
+ "搜索名称 / Key / 备注": "搜索名称 / Key / 备注",
+ "渠道类型": "渠道类型",
+ "状态": "状态",
+ "待晋升": "待晋升",
+ "已晋升": "已晋升",
+ "已归档": "已归档",
+ "晋升渠道": "晋升渠道",
+ "确认晋升?": "确认晋升?",
+ "该候选渠道会被创建为正式渠道。": "该候选渠道会被创建为正式渠道。",
+ "晋升": "晋升",
+ "归档": "归档",
+ "候选渠道更新成功": "候选渠道更新成功",
+ "候选渠道创建成功": "候选渠道创建成功",
+ "导入完成:{{count}} 条成功": "导入完成:{{count}} 条成功",
+ "候选渠道已晋升为正式渠道": "候选渠道已晋升为正式渠道",
+ "批量晋升完成:{{count}} 条成功": "批量晋升完成:{{count}} 条成功",
+ "请先选择候选渠道": "请先选择候选渠道",
+ "批量归档完成:{{count}} 条成功": "批量归档完成:{{count}} 条成功",
+ "候选渠道已归档": "候选渠道已归档",
+ "加载失败": "加载失败",
+ "保存失败": "保存失败",
+ "导入失败": "导入失败",
+ "批量晋升失败": "批量晋升失败",
+ "归档失败": "归档失败",
+ "名称不能为空": "名称不能为空",
+ "Key 不能为空": "Key 不能为空",
+ "留空则保留原 Key": "留空则保留原 Key",
+ "导入到备货池": "导入到备货池",
+ "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。": "每行格式:余额Key。导入后只进入备货池,不会创建正式渠道。",
+ "名称后缀": "名称后缀",
+ "不填则使用 Claude 默认模型": "不填则使用 Claude 默认模型"
}
diff --git a/web/classic/src/pages/ChannelPreparation/index.jsx b/web/classic/src/pages/ChannelPreparation/index.jsx
new file mode 100644
index 000000000000..29bbd18ba7dc
--- /dev/null
+++ b/web/classic/src/pages/ChannelPreparation/index.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import ChannelPreparationsPage from '../../components/table/channel-preparations';
+
+const ChannelPreparation = () => {
+ return (
+
+
+
+ );
+};
+
+export default ChannelPreparation;
From 68ca9445eb16ffc11f939e2dc12e05b213fecc91 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 16:44:11 +0800
Subject: [PATCH 15/38] build: make docker local build theme-aware
---
Dockerfile | 49 +++++++++++++++++++++++++++------------------
bin/docker-local.sh | 5 +++--
2 files changed, 33 insertions(+), 21 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index c679c2d71121..a579beb45426 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,22 +1,33 @@
-FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
+FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS frontend-builder
WORKDIR /build
-COPY web/default/package.json .
-COPY web/default/bun.lock .
-RUN bun install
-COPY ./web/default .
-COPY ./VERSION .
-RUN DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION=$(cat VERSION) bun --smol run build
-
-FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
-
-WORKDIR /build
-COPY web/classic/package.json .
-COPY web/classic/bun.lock .
-RUN bun install
-COPY ./web/classic .
-COPY ./VERSION .
-RUN BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION=$(cat VERSION) bun --smol run build
+ARG FRONTEND_THEME=all
+
+COPY web/default/package.json web/default/bun.lock ./web/default/
+COPY web/classic/package.json web/classic/bun.lock ./web/classic/
+RUN set -eux; \
+ case "${FRONTEND_THEME}" in \
+ default) cd web/default && bun install ;; \
+ classic) cd web/classic && bun install ;; \
+ all) cd web/default && bun install && cd ../classic && bun install ;; \
+ "") mkdir -p web/default/dist web/classic/dist ;; \
+ *) echo "Invalid FRONTEND_THEME: ${FRONTEND_THEME} (use default|classic|all, or empty to skip frontend build)" >&2; exit 1 ;; \
+ esac
+
+COPY ./web/default ./web/default
+COPY ./web/classic ./web/classic
+COPY ./VERSION ./VERSION
+RUN set -eux; \
+ mkdir -p /build/web/default/dist /build/web/classic/dist; \
+ case "${FRONTEND_THEME}" in \
+ default) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ classic) cd /build/web/classic && BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ all) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build && cd /build/web/classic && BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ "") ;; \
+ *) echo "Invalid FRONTEND_THEME: ${FRONTEND_THEME} (use default|classic|all, or empty to skip frontend build)" >&2; exit 1 ;; \
+ esac; \
+ if [ ! -f /build/web/default/dist/index.html ]; then printf '%s\n' 'frontend not builtfrontend theme was not built in this image' > /build/web/default/dist/index.html; fi; \
+ if [ ! -f /build/web/classic/dist/index.html ]; then printf '%s\n' 'frontend not builtfrontend theme was not built in this image' > /build/web/classic/dist/index.html; fi
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -32,8 +43,8 @@ ADD go.mod go.sum ./
RUN go mod download
COPY . .
-COPY --from=builder /build/dist ./web/default/dist
-COPY --from=builder-classic /build/dist ./web/classic/dist
+COPY --from=frontend-builder /build/web/default/dist ./web/default/dist
+COPY --from=frontend-builder /build/web/classic/dist ./web/classic/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
index 608409a21155..ebee7239e1dc 100755
--- a/bin/docker-local.sh
+++ b/bin/docker-local.sh
@@ -66,7 +66,7 @@ Common environment overrides:
PLATFORM=linux/amd64 Optional docker build --platform value
FOLLOW_LOGS=1 Follow app logs after starting
ENV_FILE=.env.local Optional extra env file for the app
- FRONTEND_THEME=default Frontend theme for local deployment (default|classic; empty to skip)
+ FRONTEND_THEME=classic Frontend theme for local deployment/build (default: classic; use default|classic; empty to skip build/theme)
Advanced overrides:
POSTGRES_PASSWORD=... Override generated PostgreSQL password
@@ -173,8 +173,9 @@ build_image() {
if [[ -n "${PLATFORM}" ]]; then
build_args+=(--platform "${PLATFORM}")
fi
+ build_args+=(--build-arg "FRONTEND_THEME=${FRONTEND_THEME}")
- log "Building Docker image ${IMAGE_NAME}"
+ log "Building Docker image ${IMAGE_NAME} (frontend theme: ${FRONTEND_THEME:-none})"
DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" docker build \
"${build_args[@]}" \
-f "${ROOT_DIR}/Dockerfile" \
From 7cf34db7ddf9ef6806be358a6184bbb04587968e Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 16:52:31 +0800
Subject: [PATCH 16/38] build: lower docker frontend build heap
---
Dockerfile | 7 ++++---
bin/docker-local.sh | 5 ++++-
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index a579beb45426..03d4bbc39c70 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,6 +2,7 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2
WORKDIR /build
ARG FRONTEND_THEME=all
+ARG FRONTEND_BUILD_GC_HEAP_SIZE=805306368
COPY web/default/package.json web/default/bun.lock ./web/default/
COPY web/classic/package.json web/classic/bun.lock ./web/classic/
@@ -20,9 +21,9 @@ COPY ./VERSION ./VERSION
RUN set -eux; \
mkdir -p /build/web/default/dist /build/web/classic/dist; \
case "${FRONTEND_THEME}" in \
- default) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
- classic) cd /build/web/classic && BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
- all) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build && cd /build/web/classic && BUN_JSC_gcMaxHeapSize=1073741824 VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ default) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize="${FRONTEND_BUILD_GC_HEAP_SIZE}" VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ classic) cd /build/web/classic && BUN_JSC_gcMaxHeapSize="${FRONTEND_BUILD_GC_HEAP_SIZE}" VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
+ all) cd /build/web/default && DISABLE_ESLINT_PLUGIN='true' BUN_JSC_gcMaxHeapSize="${FRONTEND_BUILD_GC_HEAP_SIZE}" VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build && cd /build/web/classic && BUN_JSC_gcMaxHeapSize="${FRONTEND_BUILD_GC_HEAP_SIZE}" VITE_REACT_APP_VERSION="$(cat /build/VERSION)" bun --smol run build ;; \
"") ;; \
*) echo "Invalid FRONTEND_THEME: ${FRONTEND_THEME} (use default|classic|all, or empty to skip frontend build)" >&2; exit 1 ;; \
esac; \
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
index ebee7239e1dc..3075addef167 100755
--- a/bin/docker-local.sh
+++ b/bin/docker-local.sh
@@ -40,6 +40,7 @@ SESSION_SECRET="${SESSION_SECRET:-}"
CRYPTO_SECRET="${CRYPTO_SECRET:-}"
NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}"
BUILD_ON_UP="${BUILD_ON_UP:-1}"
+FRONTEND_BUILD_GC_HEAP_SIZE="${FRONTEND_BUILD_GC_HEAP_SIZE:-805306368}"
if [[ -z "${FRONTEND_THEME+x}" ]]; then
FRONTEND_THEME="classic"
fi
@@ -67,6 +68,7 @@ Common environment overrides:
FOLLOW_LOGS=1 Follow app logs after starting
ENV_FILE=.env.local Optional extra env file for the app
FRONTEND_THEME=classic Frontend theme for local deployment/build (default: classic; use default|classic; empty to skip build/theme)
+ FRONTEND_BUILD_GC_HEAP_SIZE=805306368 Bun/JSC GC heap limit for frontend build; lower is slower but uses less memory
Advanced overrides:
POSTGRES_PASSWORD=... Override generated PostgreSQL password
@@ -174,8 +176,9 @@ build_image() {
build_args+=(--platform "${PLATFORM}")
fi
build_args+=(--build-arg "FRONTEND_THEME=${FRONTEND_THEME}")
+ build_args+=(--build-arg "FRONTEND_BUILD_GC_HEAP_SIZE=${FRONTEND_BUILD_GC_HEAP_SIZE}")
- log "Building Docker image ${IMAGE_NAME} (frontend theme: ${FRONTEND_THEME:-none})"
+ log "Building Docker image ${IMAGE_NAME} (frontend theme: ${FRONTEND_THEME:-none}, heap: ${FRONTEND_BUILD_GC_HEAP_SIZE})"
DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" docker build \
"${build_args[@]}" \
-f "${ROOT_DIR}/Dockerfile" \
From 01bd116dd1a5808d3147e3fad27b7c6bc201f613 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 16:55:46 +0800
Subject: [PATCH 17/38] build: reduce default frontend build heap
---
Dockerfile | 2 +-
bin/docker-local.sh | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 03d4bbc39c70..d4713f866c2e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2
WORKDIR /build
ARG FRONTEND_THEME=all
-ARG FRONTEND_BUILD_GC_HEAP_SIZE=805306368
+ARG FRONTEND_BUILD_GC_HEAP_SIZE=536870912
COPY web/default/package.json web/default/bun.lock ./web/default/
COPY web/classic/package.json web/classic/bun.lock ./web/classic/
diff --git a/bin/docker-local.sh b/bin/docker-local.sh
index 3075addef167..6d61be49fc09 100755
--- a/bin/docker-local.sh
+++ b/bin/docker-local.sh
@@ -40,7 +40,7 @@ SESSION_SECRET="${SESSION_SECRET:-}"
CRYPTO_SECRET="${CRYPTO_SECRET:-}"
NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}"
BUILD_ON_UP="${BUILD_ON_UP:-1}"
-FRONTEND_BUILD_GC_HEAP_SIZE="${FRONTEND_BUILD_GC_HEAP_SIZE:-805306368}"
+FRONTEND_BUILD_GC_HEAP_SIZE="${FRONTEND_BUILD_GC_HEAP_SIZE:-536870912}"
if [[ -z "${FRONTEND_THEME+x}" ]]; then
FRONTEND_THEME="classic"
fi
@@ -68,7 +68,7 @@ Common environment overrides:
FOLLOW_LOGS=1 Follow app logs after starting
ENV_FILE=.env.local Optional extra env file for the app
FRONTEND_THEME=classic Frontend theme for local deployment/build (default: classic; use default|classic; empty to skip build/theme)
- FRONTEND_BUILD_GC_HEAP_SIZE=805306368 Bun/JSC GC heap limit for frontend build; lower is slower but uses less memory
+ FRONTEND_BUILD_GC_HEAP_SIZE=536870912 Bun/JSC GC heap limit for frontend build; lower is slower but uses less memory
Advanced overrides:
POSTGRES_PASSWORD=... Override generated PostgreSQL password
From 8fd6e314ee13bcf79bfd2dd6d4209855c6334521 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 17:26:13 +0800
Subject: [PATCH 18/38] feat: delete channel preparations after promotion
---
controller/channel_preparation.go | 35 +++++++------------
router/api-router.go | 2 +-
.../PreparationActions.jsx | 10 +++---
.../PreparationColumnDefs.jsx | 19 +++-------
.../PreparationFilters.jsx | 20 -----------
.../channel-preparations/PreparationTable.jsx | 6 ++--
.../channels/useChannelPreparationsData.jsx | 24 ++++++-------
web/classic/src/i18n/locales/en.json | 19 +++++-----
web/classic/src/i18n/locales/zh-CN.json | 19 +++++-----
web/classic/src/i18n/locales/zh-TW.json | 19 +++++-----
web/classic/src/i18n/locales/zh.json | 19 +++++-----
11 files changed, 69 insertions(+), 123 deletions(-)
diff --git a/controller/channel_preparation.go b/controller/channel_preparation.go
index 52f90e3c2285..04631c7d5361 100644
--- a/controller/channel_preparation.go
+++ b/controller/channel_preparation.go
@@ -110,6 +110,10 @@ func GetChannelPreparations(c *gin.Context) {
common.ApiError(c, err)
return
}
+ if status == nil {
+ pendingStatus := model.ChannelPreparationStatusPending
+ status = &pendingStatus
+ }
opts := model.ChannelPreparationListOptions{
Page: page,
PageSize: pageSize,
@@ -198,25 +202,19 @@ func UpdateChannelPreparation(c *gin.Context) {
common.ApiSuccess(c, input.ToResponse())
}
-func ArchiveChannelPreparation(c *gin.Context) {
+func DeleteChannelPreparation(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, err)
return
}
- updates := map[string]any{
- "status": model.ChannelPreparationStatusArchived,
- "updated_time": common.GetTimestamp(),
- }
- result := model.DB.Model(&model.ChannelPreparation{}).
- Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPending).
- Updates(updates)
+ result := model.DB.Delete(&model.ChannelPreparation{}, "id = ?", id)
if result.Error != nil {
common.ApiError(c, result.Error)
return
}
if result.RowsAffected == 0 {
- common.ApiErrorMsg(c, "候选渠道不存在或不可归档")
+ common.ApiErrorMsg(c, "候选渠道不存在")
return
}
common.ApiSuccess(c, gin.H{"id": id})
@@ -292,22 +290,15 @@ func promoteChannelPreparation(id int) (int, error) {
return 0, fmt.Errorf("channel cannot be empty")
}
channelID := channels[0].Id
- updates := map[string]any{
- "status": model.ChannelPreparationStatusPromoted,
- "promoted_channel_id": channelID,
- "promoted_time": now,
- "updated_time": now,
- }
- promoteResult := tx.Model(&model.ChannelPreparation{}).
- Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPromoting).
- Updates(updates)
- if promoteResult.Error != nil {
+ deleteResult := tx.Where("id = ? AND status = ?", id, model.ChannelPreparationStatusPromoting).
+ Delete(&model.ChannelPreparation{})
+ if deleteResult.Error != nil {
tx.Rollback()
- return 0, promoteResult.Error
+ return 0, deleteResult.Error
}
- if promoteResult.RowsAffected == 0 {
+ if deleteResult.RowsAffected == 0 {
tx.Rollback()
- return 0, fmt.Errorf("候选渠道晋升状态更新失败")
+ return 0, fmt.Errorf("候选渠道晋升后删除失败")
}
if err := tx.Commit().Error; err != nil {
return 0, err
diff --git a/router/api-router.go b/router/api-router.go
index d008f666b5ef..a04724ba8512 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -256,7 +256,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/preparations/batch/promote", controller.PromoteChannelPreparationsBatch)
channelRoute.GET("/preparations/:id", controller.GetChannelPreparation)
channelRoute.PUT("/preparations/:id", controller.UpdateChannelPreparation)
- channelRoute.DELETE("/preparations/:id", controller.ArchiveChannelPreparation)
+ channelRoute.DELETE("/preparations/:id", controller.DeleteChannelPreparation)
channelRoute.POST("/preparations/:id/promote", controller.PromoteChannelPreparation)
channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
diff --git a/web/classic/src/components/table/channel-preparations/PreparationActions.jsx b/web/classic/src/components/table/channel-preparations/PreparationActions.jsx
index 877049bb12df..a83ac6ef100f 100644
--- a/web/classic/src/components/table/channel-preparations/PreparationActions.jsx
+++ b/web/classic/src/components/table/channel-preparations/PreparationActions.jsx
@@ -7,7 +7,7 @@ const PreparationActions = ({
refresh,
selectedPreparations,
promoteSelected,
- archiveSelected,
+ deleteSelected,
}) => {
const hasSelection = selectedPreparations.length > 0;
@@ -34,13 +34,13 @@ const PreparationActions = ({
disabled={!hasSelection}
onClick={() => {
Modal.confirm({
- title: t('确认批量归档?'),
- content: t('归档后候选渠道不会被晋升。'),
- onOk: archiveSelected,
+ title: t('确认批量删除?'),
+ content: t('删除后候选渠道会从备货池移除。'),
+ onOk: deleteSelected,
});
}}
>
- {t('批量归档')}
+ {t('批量删除')}
{
@@ -36,7 +34,7 @@ export const getPreparationColumns = ({
t,
openEdit,
promotePreparation,
- archivePreparation,
+ deletePreparation,
}) => [
{
title: 'ID',
@@ -113,13 +111,6 @@ export const getPreparationColumns = ({
width: 90,
render: (value) => value ?? 0,
},
- {
- title: t('晋升渠道'),
- dataIndex: 'promoted_channel_id',
- key: 'promoted_channel_id',
- width: 110,
- render: (value) => value || '-',
- },
{
title: t('创建时间'),
dataIndex: 'created_time',
@@ -165,13 +156,13 @@ export const getPreparationColumns = ({
disabled={!pending}
onClick={() => {
Modal.confirm({
- title: t('确认归档?'),
- content: t('归档后候选渠道不会被晋升。'),
- onOk: () => archivePreparation(record),
+ title: t('确认删除?'),
+ content: t('删除后候选渠道会从备货池移除。'),
+ onOk: () => deletePreparation(record),
});
}}
>
- {t('归档')}
+ {t('删除')}
);
diff --git a/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx b/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
index d047ebd2c1bb..6465d7ade8f8 100644
--- a/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
+++ b/web/classic/src/components/table/channel-preparations/PreparationFilters.jsx
@@ -1,10 +1,6 @@
import React from 'react';
import { Button, Input, Select } from '@douyinfe/semi-ui';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
-import {
- PREPARATION_STATUS,
- PREPARATION_STATUS_LABELS,
-} from '../../../hooks/channels/useChannelPreparationsData';
const PreparationFilters = ({
t,
@@ -14,8 +10,6 @@ const PreparationFilters = ({
setGroup,
type,
setType,
- status,
- setStatus,
handleSearch,
}) => {
return (
@@ -50,20 +44,6 @@ const PreparationFilters = ({
))}
-
{t('搜索')}
diff --git a/web/classic/src/components/table/channel-preparations/PreparationTable.jsx b/web/classic/src/components/table/channel-preparations/PreparationTable.jsx
index 83202bd9dbc4..50a39740796f 100644
--- a/web/classic/src/components/table/channel-preparations/PreparationTable.jsx
+++ b/web/classic/src/components/table/channel-preparations/PreparationTable.jsx
@@ -22,7 +22,7 @@ const PreparationTable = ({
setSelectedPreparations,
openEdit,
promotePreparation,
- archivePreparation,
+ deletePreparation,
}) => {
const columns = useMemo(
() =>
@@ -30,9 +30,9 @@ const PreparationTable = ({
t,
openEdit,
promotePreparation,
- archivePreparation,
+ deletePreparation,
}),
- [t, openEdit, promotePreparation, archivePreparation],
+ [t, openEdit, promotePreparation, deletePreparation],
);
return (
diff --git a/web/classic/src/hooks/channels/useChannelPreparationsData.jsx b/web/classic/src/hooks/channels/useChannelPreparationsData.jsx
index 8d59e59f5557..2c268d6f8197 100644
--- a/web/classic/src/hooks/channels/useChannelPreparationsData.jsx
+++ b/web/classic/src/hooks/channels/useChannelPreparationsData.jsx
@@ -4,14 +4,10 @@ import { API, showError, showSuccess, showInfo } from '../../helpers';
export const PREPARATION_STATUS = {
PENDING: 1,
- PROMOTED: 2,
- ARCHIVED: 3,
};
export const PREPARATION_STATUS_LABELS = {
[PREPARATION_STATUS.PENDING]: '待晋升',
- [PREPARATION_STATUS.PROMOTED]: '已晋升',
- [PREPARATION_STATUS.ARCHIVED]: '已归档',
};
const DEFAULT_PAGE_SIZE = 20;
@@ -156,7 +152,7 @@ export function useChannelPreparationsData() {
showError(res.data.message || t('晋升失败'));
return false;
}
- showSuccess(t('候选渠道已晋升为正式渠道'));
+ showSuccess(t('候选渠道已晋升为正式渠道,并已从备货池移除'));
refresh();
return true;
},
@@ -184,23 +180,23 @@ export function useChannelPreparationsData() {
refresh();
}, [selectedPreparations, refresh, t]);
- const archivePreparation = useCallback(
+ const deletePreparation = useCallback(
async (preparation) => {
const res = await API.delete(
`/api/channel/preparations/${preparation.id}`,
);
if (!res.data.success) {
- showError(res.data.message || t('归档失败'));
+ showError(res.data.message || t('删除失败'));
return false;
}
- showSuccess(t('候选渠道已归档'));
+ showSuccess(t('候选渠道已删除'));
refresh();
return true;
},
[refresh, t],
);
- const archiveSelected = useCallback(async () => {
+ const deleteSelected = useCallback(async () => {
if (selectedPreparations.length === 0) {
showInfo(t('请先选择候选渠道'));
return;
@@ -210,7 +206,7 @@ export function useChannelPreparationsData() {
const res = await API.delete(`/api/channel/preparations/${item.id}`);
if (res.data.success) successCount += 1;
}
- showSuccess(t('批量归档完成:{{count}} 条成功', { count: successCount }));
+ showSuccess(t('批量删除完成:{{count}} 条成功', { count: successCount }));
setSelectedPreparationKeys([]);
setSelectedPreparations([]);
refresh();
@@ -251,8 +247,8 @@ export function useChannelPreparationsData() {
importPreparations,
promotePreparation,
promoteSelected,
- archivePreparation,
- archiveSelected,
+ deletePreparation,
+ deleteSelected,
}),
[
t,
@@ -281,8 +277,8 @@ export function useChannelPreparationsData() {
importPreparations,
promotePreparation,
promoteSelected,
- archivePreparation,
- archiveSelected,
+ deletePreparation,
+ deleteSelected,
],
);
}
diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json
index 688f2e4e5bdc..876f22357a4b 100644
--- a/web/classic/src/i18n/locales/en.json
+++ b/web/classic/src/i18n/locales/en.json
@@ -3839,35 +3839,32 @@
"编辑候选渠道": "Edit Candidate Channel",
"导入候选渠道": "Import Candidate Channels",
"批量晋升": "Batch Promote",
- "批量归档": "Batch Archive",
+ "批量删除": "Batch Delete",
"确认批量晋升?": "Confirm batch promotion?",
"选中的候选渠道会被创建为正式渠道。": "Selected candidate channels will be created as live channels.",
- "确认批量归档?": "Confirm batch archive?",
- "归档后候选渠道不会被晋升。": "Archived candidate channels cannot be promoted.",
+ "确认批量删除?": "Confirm batch delete?",
+ "删除后候选渠道会从备货池移除。": "Deleted candidate channels will be removed from the prep pool.",
"搜索名称 / Key / 备注": "Search name / Key / note",
"渠道类型": "Channel Type",
"状态": "Status",
"待晋升": "Pending",
- "已晋升": "Promoted",
- "已归档": "Archived",
- "晋升渠道": "Promoted Channel",
"确认晋升?": "Confirm promotion?",
"该候选渠道会被创建为正式渠道。": "This candidate channel will be created as a live channel.",
"晋升": "Promote",
- "归档": "Archive",
+ "删除": "Delete",
"候选渠道更新成功": "Candidate channel updated",
"候选渠道创建成功": "Candidate channel created",
"导入完成:{{count}} 条成功": "Import complete: {{count}} succeeded",
- "候选渠道已晋升为正式渠道": "Candidate channel promoted to live channel",
+ "候选渠道已晋升为正式渠道,并已从备货池移除": "Candidate channel promoted to live channel and removed from the prep pool",
"批量晋升完成:{{count}} 条成功": "Batch promotion complete: {{count}} succeeded",
"请先选择候选渠道": "Please select candidate channels first",
- "批量归档完成:{{count}} 条成功": "Batch archive complete: {{count}} succeeded",
- "候选渠道已归档": "Candidate channel archived",
+ "批量删除完成:{{count}} 条成功": "Batch delete complete: {{count}} succeeded",
+ "候选渠道已删除": "Candidate channel deleted",
"加载失败": "Failed to load",
"保存失败": "Failed to save",
"导入失败": "Import failed",
"批量晋升失败": "Batch promotion failed",
- "归档失败": "Archive failed",
+ "删除失败": "Delete failed",
"名称不能为空": "Name cannot be empty",
"Key 不能为空": "Key cannot be empty",
"留空则保留原 Key": "Leave empty to keep the existing key",
diff --git a/web/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json
index 7b4f14870049..cbbf61cbb671 100644
--- a/web/classic/src/i18n/locales/zh-CN.json
+++ b/web/classic/src/i18n/locales/zh-CN.json
@@ -3822,35 +3822,32 @@
"编辑候选渠道": "编辑候选渠道",
"导入候选渠道": "导入候选渠道",
"批量晋升": "批量晋升",
- "批量归档": "批量归档",
+ "批量删除": "批量删除",
"确认批量晋升?": "确认批量晋升?",
"选中的候选渠道会被创建为正式渠道。": "选中的候选渠道会被创建为正式渠道。",
- "确认批量归档?": "确认批量归档?",
- "归档后候选渠道不会被晋升。": "归档后候选渠道不会被晋升。",
+ "确认批量删除?": "确认批量删除?",
+ "删除后候选渠道会从备货池移除。": "删除后候选渠道会从备货池移除。",
"搜索名称 / Key / 备注": "搜索名称 / Key / 备注",
"渠道类型": "渠道类型",
"状态": "状态",
"待晋升": "待晋升",
- "已晋升": "已晋升",
- "已归档": "已归档",
- "晋升渠道": "晋升渠道",
"确认晋升?": "确认晋升?",
"该候选渠道会被创建为正式渠道。": "该候选渠道会被创建为正式渠道。",
"晋升": "晋升",
- "归档": "归档",
+ "删除": "删除",
"候选渠道更新成功": "候选渠道更新成功",
"候选渠道创建成功": "候选渠道创建成功",
"导入完成:{{count}} 条成功": "导入完成:{{count}} 条成功",
- "候选渠道已晋升为正式渠道": "候选渠道已晋升为正式渠道",
+ "候选渠道已晋升为正式渠道,并已从备货池移除": "候选渠道已晋升为正式渠道,并已从备货池移除",
"批量晋升完成:{{count}} 条成功": "批量晋升完成:{{count}} 条成功",
"请先选择候选渠道": "请先选择候选渠道",
- "批量归档完成:{{count}} 条成功": "批量归档完成:{{count}} 条成功",
- "候选渠道已归档": "候选渠道已归档",
+ "批量删除完成:{{count}} 条成功": "批量删除完成:{{count}} 条成功",
+ "候选渠道已删除": "候选渠道已删除",
"加载失败": "加载失败",
"保存失败": "保存失败",
"导入失败": "导入失败",
"批量晋升失败": "批量晋升失败",
- "归档失败": "归档失败",
+ "删除失败": "删除失败",
"名称不能为空": "名称不能为空",
"Key 不能为空": "Key 不能为空",
"留空则保留原 Key": "留空则保留原 Key",
diff --git a/web/classic/src/i18n/locales/zh-TW.json b/web/classic/src/i18n/locales/zh-TW.json
index d2b53f6d42fc..76472227bd8b 100644
--- a/web/classic/src/i18n/locales/zh-TW.json
+++ b/web/classic/src/i18n/locales/zh-TW.json
@@ -3686,35 +3686,32 @@
"编辑候选渠道": "編輯候選渠道",
"导入候选渠道": "匯入候選渠道",
"批量晋升": "批次晉升",
- "批量归档": "批次歸檔",
+ "批量删除": "批次刪除",
"确认批量晋升?": "確認批次晉升?",
"选中的候选渠道会被创建为正式渠道。": "選中的候選渠道會被建立為正式渠道。",
- "确认批量归档?": "確認批次歸檔?",
- "归档后候选渠道不会被晋升。": "歸檔後候選渠道不會被晉升。",
+ "确认批量删除?": "確認批次刪除?",
+ "删除后候选渠道会从备货池移除。": "刪除後候選渠道會從備貨池移除。",
"搜索名称 / Key / 备注": "搜尋名稱 / Key / 備註",
"渠道类型": "渠道類型",
"状态": "狀態",
"待晋升": "待晉升",
- "已晋升": "已晉升",
- "已归档": "已歸檔",
- "晋升渠道": "晉升渠道",
"确认晋升?": "確認晉升?",
"该候选渠道会被创建为正式渠道。": "該候選渠道會被建立為正式渠道。",
"晋升": "晉升",
- "归档": "歸檔",
+ "删除": "刪除",
"候选渠道更新成功": "候選渠道更新成功",
"候选渠道创建成功": "候選渠道建立成功",
"导入完成:{{count}} 条成功": "匯入完成:{{count}} 條成功",
- "候选渠道已晋升为正式渠道": "候選渠道已晉升為正式渠道",
+ "候选渠道已晋升为正式渠道,并已从备货池移除": "候選渠道已晉升為正式渠道,並已從備貨池移除",
"批量晋升完成:{{count}} 条成功": "批次晉升完成:{{count}} 條成功",
"请先选择候选渠道": "請先選擇候選渠道",
- "批量归档完成:{{count}} 条成功": "批次歸檔完成:{{count}} 條成功",
- "候选渠道已归档": "候選渠道已歸檔",
+ "批量删除完成:{{count}} 条成功": "批次刪除完成:{{count}} 條成功",
+ "候选渠道已删除": "候選渠道已刪除",
"加载失败": "載入失敗",
"保存失败": "儲存失敗",
"导入失败": "匯入失敗",
"批量晋升失败": "批次晉升失敗",
- "归档失败": "歸檔失敗",
+ "删除失败": "刪除失敗",
"名称不能为空": "名稱不能為空",
"Key 不能为空": "Key 不能為空",
"留空则保留原 Key": "留空則保留原 Key",
diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json
index 52f2e6669aaa..f0416105419b 100644
--- a/web/classic/src/i18n/locales/zh.json
+++ b/web/classic/src/i18n/locales/zh.json
@@ -2639,35 +2639,32 @@
"编辑候选渠道": "编辑候选渠道",
"导入候选渠道": "导入候选渠道",
"批量晋升": "批量晋升",
- "批量归档": "批量归档",
+ "批量删除": "批量删除",
"确认批量晋升?": "确认批量晋升?",
"选中的候选渠道会被创建为正式渠道。": "选中的候选渠道会被创建为正式渠道。",
- "确认批量归档?": "确认批量归档?",
- "归档后候选渠道不会被晋升。": "归档后候选渠道不会被晋升。",
+ "确认批量删除?": "确认批量删除?",
+ "删除后候选渠道会从备货池移除。": "删除后候选渠道会从备货池移除。",
"搜索名称 / Key / 备注": "搜索名称 / Key / 备注",
"渠道类型": "渠道类型",
"状态": "状态",
"待晋升": "待晋升",
- "已晋升": "已晋升",
- "已归档": "已归档",
- "晋升渠道": "晋升渠道",
"确认晋升?": "确认晋升?",
"该候选渠道会被创建为正式渠道。": "该候选渠道会被创建为正式渠道。",
"晋升": "晋升",
- "归档": "归档",
+ "删除": "删除",
"候选渠道更新成功": "候选渠道更新成功",
"候选渠道创建成功": "候选渠道创建成功",
"导入完成:{{count}} 条成功": "导入完成:{{count}} 条成功",
- "候选渠道已晋升为正式渠道": "候选渠道已晋升为正式渠道",
+ "候选渠道已晋升为正式渠道,并已从备货池移除": "候选渠道已晋升为正式渠道,并已从备货池移除",
"批量晋升完成:{{count}} 条成功": "批量晋升完成:{{count}} 条成功",
"请先选择候选渠道": "请先选择候选渠道",
- "批量归档完成:{{count}} 条成功": "批量归档完成:{{count}} 条成功",
- "候选渠道已归档": "候选渠道已归档",
+ "批量删除完成:{{count}} 条成功": "批量删除完成:{{count}} 条成功",
+ "候选渠道已删除": "候选渠道已删除",
"加载失败": "加载失败",
"保存失败": "保存失败",
"导入失败": "导入失败",
"批量晋升失败": "批量晋升失败",
- "归档失败": "归档失败",
+ "删除失败": "删除失败",
"名称不能为空": "名称不能为空",
"Key 不能为空": "Key 不能为空",
"留空则保留原 Key": "留空则保留原 Key",
From f8afb93938493574833370942482bb4dab94e515 Mon Sep 17 00:00:00 2001
From: z23cc
Date: Tue, 2 Jun 2026 20:13:52 +0800
Subject: [PATCH 19/38] feat: add channel preparation auto promotion
---
.../channel_preparation_auto_promotion.go | 462 +++++++++++++++
controller/option.go | 27 +
main.go | 1 +
router/api-router.go | 1 +
...nnel_preparation_auto_promotion_setting.go | 115 ++++
.../AutoPromotionPanel.jsx | 540 ++++++++++++++++++
.../table/channel-preparations/index.jsx | 2 +
7 files changed, 1148 insertions(+)
create mode 100644 controller/channel_preparation_auto_promotion.go
create mode 100644 setting/operation_setting/channel_preparation_auto_promotion_setting.go
create mode 100644 web/classic/src/components/table/channel-preparations/AutoPromotionPanel.jsx
diff --git a/controller/channel_preparation_auto_promotion.go b/controller/channel_preparation_auto_promotion.go
new file mode 100644
index 000000000000..2646372d5fd2
--- /dev/null
+++ b/controller/channel_preparation_auto_promotion.go
@@ -0,0 +1,462 @@
+package controller
+
+import (
+ "fmt"
+ "io"
+ "math"
+ "math/rand"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/setting/operation_setting"
+
+ "github.com/gin-gonic/gin"
+)
+
+const channelPreparationAutoPromotionTriggerManual = "manual"
+const channelPreparationAutoPromotionTriggerScheduler = "scheduler"
+
+type channelPreparationAutoPromotionRunRequest struct {
+ RuleId string `json:"rule_id"`
+}
+
+type channelPreparationAutoPromotionCapacitySummary struct {
+ EligibleChannelCount int64 `json:"eligible_channel_count"`
+ IgnoredNonPositiveBalanceChannelCount int64 `json:"ignored_non_positive_balance_channel_count"`
+ BalanceSumUSD float64 `json:"balance_sum_usd"`
+ UsedQuotaUSD float64 `json:"used_quota_usd"`
+ EffectiveCapacityUSD float64 `json:"effective_capacity_usd"`
+ RawEffectiveCapacityUSD float64 `json:"raw_effective_capacity_usd"`
+}
+
+type channelPreparationAutoPromotionStep struct {
+ PreparationId int `json:"preparation_id"`
+ ChannelId int `json:"channel_id"`
+ CandidateBalanceUSD float64 `json:"candidate_balance_usd"`
+ CapacityBeforeUSD float64 `json:"capacity_before_usd"`
+ CapacityAfterUSD float64 `json:"capacity_after_usd"`
+}
+
+type channelPreparationAutoPromotionRuleSummary struct {
+ Trigger string `json:"trigger"`
+ RuleId string `json:"rule_id"`
+ Group string `json:"group"`
+ Type int `json:"type"`
+ Strategy string `json:"strategy"`
+ ThresholdUSD float64 `json:"threshold_usd"`
+ InitialCapacity channelPreparationAutoPromotionCapacitySummary `json:"initial_capacity"`
+ FinalCapacity channelPreparationAutoPromotionCapacitySummary `json:"final_capacity"`
+ Promotions []channelPreparationAutoPromotionStep `json:"promotions"`
+ Failures []string `json:"failures"`
+ SkippedReason string `json:"skipped_reason,omitempty"`
+ RemainingDeficitUSD float64 `json:"remaining_deficit_usd"`
+ LimitReached bool `json:"limit_reached"`
+}
+
+type channelPreparationAutoPromotionRunSummary struct {
+ Trigger string `json:"trigger"`
+ RuleId string `json:"rule_id,omitempty"`
+ StartedAt int64 `json:"started_at"`
+ FinishedAt int64 `json:"finished_at"`
+ MaxPromotions int `json:"max_promotions"`
+ TotalPromoted int `json:"total_promoted"`
+ LimitReached bool `json:"limit_reached"`
+ Rules []channelPreparationAutoPromotionRuleSummary `json:"rules"`
+ SkippedReason string `json:"skipped_reason,omitempty"`
+}
+
+type channelPreparationAutoPromotionCapacityAggregate struct {
+ EligibleChannelCount int64 `gorm:"column:eligible_channel_count"`
+ BalanceSumUSD float64 `gorm:"column:balance_sum_usd"`
+ UsedQuotaSum int64 `gorm:"column:used_quota_sum"`
+}
+
+var (
+ channelPreparationAutoPromotionRunMutex sync.Mutex
+ channelPreparationAutoPromotionTaskOnce sync.Once
+)
+
+func normalizeAutoPromotionDeficit(threshold float64, capacity float64) float64 {
+ deficit := threshold - capacity
+ if deficit < 0 {
+ return 0
+ }
+ return deficit
+}
+
+func safeQuotaToUSD(usedQuota int64) float64 {
+ if common.QuotaPerUnit <= 0 {
+ return 0
+ }
+ return float64(usedQuota) / common.QuotaPerUnit
+}
+
+func computeChannelPreparationAutoPromotionCapacity(group string, channelType int) (channelPreparationAutoPromotionCapacitySummary, error) {
+ query := model.DB.Model(&model.Channel{})
+ query = model.ApplyChannelGroupFilter(query, group)
+ query = query.Where("status = ?", common.ChannelStatusEnabled).
+ Where("type = ?", channelType).
+ Where("balance > ?", 0)
+
+ var aggregate channelPreparationAutoPromotionCapacityAggregate
+ if err := query.Select("COUNT(*) AS eligible_channel_count, COALESCE(SUM(balance), 0) AS balance_sum_usd, COALESCE(SUM(used_quota), 0) AS used_quota_sum").Scan(&aggregate).Error; err != nil {
+ return channelPreparationAutoPromotionCapacitySummary{}, err
+ }
+
+ ignoredQuery := model.DB.Model(&model.Channel{})
+ ignoredQuery = model.ApplyChannelGroupFilter(ignoredQuery, group)
+ ignoredQuery = ignoredQuery.Where("status = ?", common.ChannelStatusEnabled).
+ Where("type = ?", channelType).
+ Where("balance <= ?", 0)
+ var ignoredCount int64
+ if err := ignoredQuery.Count(&ignoredCount).Error; err != nil {
+ return channelPreparationAutoPromotionCapacitySummary{}, err
+ }
+
+ usedQuotaUSD := safeQuotaToUSD(aggregate.UsedQuotaSum)
+ rawCapacity := aggregate.BalanceSumUSD - usedQuotaUSD
+ capacity := rawCapacity
+ if capacity < 0 {
+ capacity = 0
+ }
+ return channelPreparationAutoPromotionCapacitySummary{
+ EligibleChannelCount: aggregate.EligibleChannelCount,
+ IgnoredNonPositiveBalanceChannelCount: ignoredCount,
+ BalanceSumUSD: aggregate.BalanceSumUSD,
+ UsedQuotaUSD: usedQuotaUSD,
+ EffectiveCapacityUSD: capacity,
+ RawEffectiveCapacityUSD: rawCapacity,
+ }, nil
+}
+
+func loadChannelPreparationAutoPromotionCandidates(group string, channelType int, excludedIds map[int]bool) ([]model.ChannelPreparation, error) {
+ query := model.DB.Model(&model.ChannelPreparation{})
+ query = model.ApplyChannelGroupFilter(query, group)
+ query = query.Where("status = ?", model.ChannelPreparationStatusPending).
+ Where("type = ?", channelType).
+ Where("balance > ?", 0)
+ if len(excludedIds) > 0 {
+ ids := make([]int, 0, len(excludedIds))
+ for id := range excludedIds {
+ ids = append(ids, id)
+ }
+ query = query.Where("id NOT IN ?", ids)
+ }
+
+ var preparations []model.ChannelPreparation
+ if err := query.Order("priority DESC, id ASC").Find(&preparations).Error; err != nil {
+ return nil, err
+ }
+ return preparations, nil
+}
+
+func preparationPriority(preparation model.ChannelPreparation) int64 {
+ if preparation.Priority == nil {
+ return 0
+ }
+ return *preparation.Priority
+}
+
+func preparationWeight(preparation model.ChannelPreparation) int64 {
+ weight := int64(0)
+ if preparation.Weight != nil {
+ weight = int64(*preparation.Weight)
+ }
+ return weight + 10
+}
+
+func chooseChannelPreparationAutoPromotionCandidate(preparations []model.ChannelPreparation, rng *rand.Rand) (model.ChannelPreparation, bool) {
+ if len(preparations) == 0 {
+ return model.ChannelPreparation{}, false
+ }
+ sort.SliceStable(preparations, func(i, j int) bool {
+ pi := preparationPriority(preparations[i])
+ pj := preparationPriority(preparations[j])
+ if pi == pj {
+ return preparations[i].Id < preparations[j].Id
+ }
+ return pi > pj
+ })
+ topPriority := preparationPriority(preparations[0])
+ tier := make([]model.ChannelPreparation, 0)
+ for _, preparation := range preparations {
+ if preparationPriority(preparation) != topPriority {
+ break
+ }
+ tier = append(tier, preparation)
+ }
+ if len(tier) == 1 {
+ return tier[0], true
+ }
+ totalWeight := int64(0)
+ for _, preparation := range tier {
+ weight := preparationWeight(preparation)
+ if weight > 0 {
+ totalWeight += weight
+ }
+ }
+ if totalWeight <= 0 {
+ return tier[0], true
+ }
+ pick := rng.Int63n(totalWeight)
+ for _, preparation := range tier {
+ weight := preparationWeight(preparation)
+ if weight <= 0 {
+ continue
+ }
+ if pick < weight {
+ return preparation, true
+ }
+ pick -= weight
+ }
+ return tier[len(tier)-1], true
+}
+
+func normalizeChannelPreparationAutoPromotionRules(rules []operation_setting.ChannelPreparationAutoPromotionRule) []operation_setting.ChannelPreparationAutoPromotionRule {
+ normalized := make([]operation_setting.ChannelPreparationAutoPromotionRule, 0, len(rules))
+ for _, rule := range rules {
+ operation_setting.NormalizeChannelPreparationAutoPromotionRule(&rule)
+ normalized = append(normalized, rule)
+ }
+ return normalized
+}
+
+func runChannelPreparationAutoPromotionLocked(trigger string, optionalRuleId string, adminUserId *int) (channelPreparationAutoPromotionRunSummary, error) {
+ settingSnapshot := *operation_setting.GetChannelPreparationAutoPromotionSetting()
+ settingSnapshot.Rules = normalizeChannelPreparationAutoPromotionRules(settingSnapshot.Rules)
+ maxPromotions := settingSnapshot.MaxPromotionsPerRun
+ if maxPromotions <= 0 {
+ maxPromotions = 10
+ }
+
+ summary := channelPreparationAutoPromotionRunSummary{
+ Trigger: trigger,
+ RuleId: strings.TrimSpace(optionalRuleId),
+ StartedAt: common.GetTimestamp(),
+ MaxPromotions: maxPromotions,
+ Rules: []channelPreparationAutoPromotionRuleSummary{},
+ }
+
+ if len(settingSnapshot.Rules) == 0 {
+ summary.SkippedReason = "没有配置自动晋升规则"
+ summary.FinishedAt = common.GetTimestamp()
+ return summary, nil
+ }
+
+ rng := rand.New(rand.NewSource(time.Now().UnixNano()))
+ promotedAny := false
+
+ for _, rule := range settingSnapshot.Rules {
+ if summary.TotalPromoted >= maxPromotions {
+ summary.LimitReached = true
+ break
+ }
+ if summary.RuleId != "" && rule.Id != summary.RuleId {
+ continue
+ }
+
+ ruleSummary := channelPreparationAutoPromotionRuleSummary{
+ Trigger: trigger,
+ RuleId: rule.Id,
+ Group: rule.Group,
+ Type: rule.Type,
+ Strategy: rule.Strategy,
+ ThresholdUSD: rule.ThresholdUSD,
+ Promotions: []channelPreparationAutoPromotionStep{},
+ Failures: []string{},
+ }
+
+ if !rule.Enabled {
+ ruleSummary.SkippedReason = "规则未启用"
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+ if strings.TrimSpace(rule.Group) == "" {
+ ruleSummary.SkippedReason = "规则分组为空"
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+ if rule.Type <= 0 {
+ ruleSummary.SkippedReason = "渠道类型无效"
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+ if rule.ThresholdUSD <= 0 {
+ ruleSummary.SkippedReason = "阈值必须大于 0"
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+ if !operation_setting.IsSupportedChannelPreparationAutoPromotionStrategy(rule.Strategy) {
+ ruleSummary.SkippedReason = "策略不支持"
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+
+ capacity, err := computeChannelPreparationAutoPromotionCapacity(rule.Group, rule.Type)
+ if err != nil {
+ ruleSummary.Failures = append(ruleSummary.Failures, err.Error())
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+ ruleSummary.InitialCapacity = capacity
+ ruleSummary.FinalCapacity = capacity
+ currentCapacity := capacity.EffectiveCapacityUSD
+ if currentCapacity >= rule.ThresholdUSD {
+ ruleSummary.SkippedReason = "容量已达标"
+ ruleSummary.RemainingDeficitUSD = 0
+ summary.Rules = append(summary.Rules, ruleSummary)
+ continue
+ }
+
+ failedCandidateIds := make(map[int]bool)
+ for currentCapacity < rule.ThresholdUSD && summary.TotalPromoted < maxPromotions {
+ latestCapacity, err := computeChannelPreparationAutoPromotionCapacity(rule.Group, rule.Type)
+ if err != nil {
+ ruleSummary.Failures = append(ruleSummary.Failures, err.Error())
+ break
+ }
+ ruleSummary.FinalCapacity = latestCapacity
+ currentCapacity = latestCapacity.EffectiveCapacityUSD
+ if currentCapacity >= rule.ThresholdUSD {
+ break
+ }
+
+ candidates, err := loadChannelPreparationAutoPromotionCandidates(rule.Group, rule.Type, failedCandidateIds)
+ if err != nil {
+ ruleSummary.Failures = append(ruleSummary.Failures, err.Error())
+ break
+ }
+ candidate, ok := chooseChannelPreparationAutoPromotionCandidate(candidates, rng)
+ if !ok {
+ ruleSummary.SkippedReason = "没有余额大于 0 的待晋升候选渠道"
+ break
+ }
+
+ before := currentCapacity
+ channelId, err := promoteChannelPreparation(candidate.Id)
+ if err != nil {
+ failedCandidateIds[candidate.Id] = true
+ ruleSummary.Failures = append(ruleSummary.Failures, fmt.Sprintf("候选渠道 %d 晋升失败:%s", candidate.Id, err.Error()))
+ continue
+ }
+ promotedAny = true
+ summary.TotalPromoted++
+ afterCapacity, capacityErr := computeChannelPreparationAutoPromotionCapacity(rule.Group, rule.Type)
+ if capacityErr != nil {
+ ruleSummary.Failures = append(ruleSummary.Failures, fmt.Sprintf("候选渠道 %d 晋升后重新计算容量失败:%s", candidate.Id, capacityErr.Error()))
+ currentCapacity = before + math.Max(candidate.Balance, 0)
+ } else {
+ ruleSummary.FinalCapacity = afterCapacity
+ currentCapacity = afterCapacity.EffectiveCapacityUSD
+ }
+ ruleSummary.Promotions = append(ruleSummary.Promotions, channelPreparationAutoPromotionStep{
+ PreparationId: candidate.Id,
+ ChannelId: channelId,
+ CandidateBalanceUSD: candidate.Balance,
+ CapacityBeforeUSD: before,
+ CapacityAfterUSD: currentCapacity,
+ })
+ logContent := fmt.Sprintf("自动晋升候选渠道:规则=%s 分组=%s 类型=%d 候选ID=%d 渠道ID=%d 余额=%.4f 容量 %.4f -> %.4f 触发=%s", rule.Id, rule.Group, rule.Type, candidate.Id, channelId, candidate.Balance, before, currentCapacity, trigger)
+ common.SysLog(logContent)
+ if adminUserId != nil && *adminUserId > 0 {
+ model.RecordLogWithAdminInfo(*adminUserId, model.LogTypeManage, logContent, map[string]interface{}{
+ "rule_id": rule.Id,
+ "group": rule.Group,
+ "type": rule.Type,
+ "preparation_id": candidate.Id,
+ "channel_id": channelId,
+ "candidate_balance": candidate.Balance,
+ "capacity_before": before,
+ "capacity_after": currentCapacity,
+ "trigger": trigger,
+ })
+ }
+ }
+
+ if summary.TotalPromoted >= maxPromotions && currentCapacity < rule.ThresholdUSD {
+ ruleSummary.LimitReached = true
+ summary.LimitReached = true
+ }
+ ruleSummary.RemainingDeficitUSD = normalizeAutoPromotionDeficit(rule.ThresholdUSD, currentCapacity)
+ summary.Rules = append(summary.Rules, ruleSummary)
+ }
+
+ if summary.RuleId != "" && len(summary.Rules) == 0 {
+ summary.SkippedReason = "未找到指定规则"
+ }
+ if promotedAny {
+ model.InitChannelCache()
+ service.ResetProxyClientCache()
+ }
+ summary.FinishedAt = common.GetTimestamp()
+ return summary, nil
+}
+
+func RunChannelPreparationAutoPromotion(trigger string, optionalRuleId string, adminUserId *int) (channelPreparationAutoPromotionRunSummary, error) {
+ if trigger == "" {
+ trigger = channelPreparationAutoPromotionTriggerManual
+ }
+ if !channelPreparationAutoPromotionRunMutex.TryLock() {
+ return channelPreparationAutoPromotionRunSummary{}, fmt.Errorf("自动晋升正在执行中")
+ }
+ defer channelPreparationAutoPromotionRunMutex.Unlock()
+ return runChannelPreparationAutoPromotionLocked(trigger, optionalRuleId, adminUserId)
+}
+
+func RunChannelPreparationAutoPromotionManually(c *gin.Context) {
+ var request channelPreparationAutoPromotionRunRequest
+ if err := c.ShouldBindJSON(&request); err != nil && err != io.EOF {
+ common.ApiError(c, err)
+ return
+ }
+ adminUserId := c.GetInt("id")
+ summary, err := RunChannelPreparationAutoPromotion(channelPreparationAutoPromotionTriggerManual, request.RuleId, &adminUserId)
+ if err != nil {
+ common.ApiErrorMsg(c, err.Error())
+ return
+ }
+ model.RecordLogWithAdminInfo(adminUserId, model.LogTypeManage, fmt.Sprintf("手动执行渠道备货池自动晋升:晋升 %d 个渠道", summary.TotalPromoted), map[string]interface{}{
+ "rule_id": request.RuleId,
+ "total_promoted": summary.TotalPromoted,
+ "limit_reached": summary.LimitReached,
+ })
+ common.ApiSuccess(c, summary)
+}
+
+func StartChannelPreparationAutoPromotionTask() {
+ channelPreparationAutoPromotionTaskOnce.Do(func() {
+ if !common.IsMasterNode {
+ return
+ }
+ go func() {
+ common.SysLog("channel preparation auto promotion task started")
+ for {
+ setting := operation_setting.GetChannelPreparationAutoPromotionSetting()
+ if !setting.SchedulerEnabled {
+ time.Sleep(1 * time.Minute)
+ continue
+ }
+ intervalMinutes := int(math.Round(setting.IntervalMinutes))
+ if intervalMinutes <= 0 {
+ intervalMinutes = 10
+ }
+ time.Sleep(time.Duration(intervalMinutes) * time.Minute)
+ if !operation_setting.GetChannelPreparationAutoPromotionSetting().SchedulerEnabled {
+ continue
+ }
+ common.SysLog(fmt.Sprintf("running channel preparation auto promotion with interval %d minutes", intervalMinutes))
+ summary, err := RunChannelPreparationAutoPromotion(channelPreparationAutoPromotionTriggerScheduler, "", nil)
+ if err != nil {
+ common.SysError("channel preparation auto promotion failed: " + err.Error())
+ continue
+ }
+ common.SysLog(fmt.Sprintf("channel preparation auto promotion finished: promoted=%d, limit_reached=%v", summary.TotalPromoted, summary.LimitReached))
+ }
+ }()
+ })
+}
diff --git a/controller/option.go b/controller/option.go
index b5fdfdc1515b..84ddb818bc2c 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -295,6 +295,33 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "channel_preparation_auto_promotion_setting.rules":
+ err = operation_setting.ValidateChannelPreparationAutoPromotionRulesJSONString(option.Value.(string))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "自动晋升规则配置错误: " + err.Error(),
+ })
+ return
+ }
+ case "channel_preparation_auto_promotion_setting.interval_minutes":
+ interval, parseErr := strconv.ParseFloat(option.Value.(string), 64)
+ if parseErr != nil || interval <= 0 {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "自动晋升检查间隔必须大于 0",
+ })
+ return
+ }
+ case "channel_preparation_auto_promotion_setting.max_promotions_per_run":
+ limit, parseErr := strconv.Atoi(option.Value.(string))
+ if parseErr != nil || limit <= 0 {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "自动晋升每次最大数量必须大于 0",
+ })
+ return
+ }
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
diff --git a/main.go b/main.go
index 3361b8ce9338..2d4d0a4bc7d6 100644
--- a/main.go
+++ b/main.go
@@ -112,6 +112,7 @@ func main() {
}
go controller.AutomaticallyTestChannels()
+ controller.StartChannelPreparationAutoPromotionTask()
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
service.StartCodexCredentialAutoRefreshTask()
diff --git a/router/api-router.go b/router/api-router.go
index a04724ba8512..040865d542e9 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -254,6 +254,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/preparations", controller.AddChannelPreparation)
channelRoute.POST("/preparations/import", controller.ImportChannelPreparations)
channelRoute.POST("/preparations/batch/promote", controller.PromoteChannelPreparationsBatch)
+ channelRoute.POST("/preparations/auto-promotion/run", controller.RunChannelPreparationAutoPromotionManually)
channelRoute.GET("/preparations/:id", controller.GetChannelPreparation)
channelRoute.PUT("/preparations/:id", controller.UpdateChannelPreparation)
channelRoute.DELETE("/preparations/:id", controller.DeleteChannelPreparation)
diff --git a/setting/operation_setting/channel_preparation_auto_promotion_setting.go b/setting/operation_setting/channel_preparation_auto_promotion_setting.go
new file mode 100644
index 000000000000..86a91f41b902
--- /dev/null
+++ b/setting/operation_setting/channel_preparation_auto_promotion_setting.go
@@ -0,0 +1,115 @@
+package operation_setting
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/setting/config"
+)
+
+const (
+ ChannelPreparationAutoPromotionStrategyPriorityWeighted = "priority_weighted"
+)
+
+type ChannelPreparationAutoPromotionRule struct {
+ Id string `json:"id"`
+ Enabled bool `json:"enabled"`
+ Group string `json:"group"`
+ Type int `json:"type"`
+ ThresholdUSD float64 `json:"threshold_usd"`
+ Strategy string `json:"strategy"`
+}
+
+type ChannelPreparationAutoPromotionSetting struct {
+ SchedulerEnabled bool `json:"scheduler_enabled"`
+ IntervalMinutes float64 `json:"interval_minutes"`
+ MaxPromotionsPerRun int `json:"max_promotions_per_run"`
+ Rules []ChannelPreparationAutoPromotionRule `json:"rules"`
+}
+
+var channelPreparationAutoPromotionSetting = ChannelPreparationAutoPromotionSetting{
+ SchedulerEnabled: false,
+ IntervalMinutes: 10,
+ MaxPromotionsPerRun: 10,
+ Rules: []ChannelPreparationAutoPromotionRule{},
+}
+
+func init() {
+ config.GlobalConfig.Register("channel_preparation_auto_promotion_setting", &channelPreparationAutoPromotionSetting)
+}
+
+func GetChannelPreparationAutoPromotionSetting() *ChannelPreparationAutoPromotionSetting {
+ NormalizeChannelPreparationAutoPromotionSetting(&channelPreparationAutoPromotionSetting)
+ return &channelPreparationAutoPromotionSetting
+}
+
+func NormalizeChannelPreparationAutoPromotionSetting(setting *ChannelPreparationAutoPromotionSetting) {
+ if setting == nil {
+ return
+ }
+ if setting.IntervalMinutes <= 0 {
+ setting.IntervalMinutes = 10
+ }
+ if setting.MaxPromotionsPerRun <= 0 {
+ setting.MaxPromotionsPerRun = 10
+ }
+ for index := range setting.Rules {
+ NormalizeChannelPreparationAutoPromotionRule(&setting.Rules[index])
+ }
+}
+
+func NormalizeChannelPreparationAutoPromotionRule(rule *ChannelPreparationAutoPromotionRule) {
+ if rule == nil {
+ return
+ }
+ rule.Id = strings.TrimSpace(rule.Id)
+ rule.Group = strings.TrimSpace(rule.Group)
+ if rule.Strategy == "" || !IsSupportedChannelPreparationAutoPromotionStrategy(rule.Strategy) {
+ rule.Strategy = ChannelPreparationAutoPromotionStrategyPriorityWeighted
+ }
+}
+
+func IsSupportedChannelPreparationAutoPromotionStrategy(strategy string) bool {
+ return strategy == ChannelPreparationAutoPromotionStrategyPriorityWeighted
+}
+
+func ValidateChannelPreparationAutoPromotionRules(rules []ChannelPreparationAutoPromotionRule) error {
+ seenIds := make(map[string]bool)
+ for index := range rules {
+ rule := rules[index]
+ NormalizeChannelPreparationAutoPromotionRule(&rule)
+ if rule.Id == "" {
+ return fmt.Errorf("第 %d 条规则缺少 id", index+1)
+ }
+ if seenIds[rule.Id] {
+ return fmt.Errorf("规则 id 重复:%s", rule.Id)
+ }
+ seenIds[rule.Id] = true
+ if strings.TrimSpace(rule.Group) == "" {
+ return fmt.Errorf("第 %d 条规则缺少分组", index+1)
+ }
+ if rule.Type <= 0 {
+ return fmt.Errorf("第 %d 条规则渠道类型无效", index+1)
+ }
+ if rule.ThresholdUSD <= 0 {
+ return fmt.Errorf("第 %d 条规则阈值必须大于 0", index+1)
+ }
+ if !IsSupportedChannelPreparationAutoPromotionStrategy(rule.Strategy) {
+ return fmt.Errorf("第 %d 条规则策略无效", index+1)
+ }
+ }
+ return nil
+}
+
+func ValidateChannelPreparationAutoPromotionRulesJSONString(value string) error {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ value = "[]"
+ }
+ var rules []ChannelPreparationAutoPromotionRule
+ if err := common.Unmarshal([]byte(value), &rules); err != nil {
+ return err
+ }
+ return ValidateChannelPreparationAutoPromotionRules(rules)
+}
diff --git a/web/classic/src/components/table/channel-preparations/AutoPromotionPanel.jsx b/web/classic/src/components/table/channel-preparations/AutoPromotionPanel.jsx
new file mode 100644
index 000000000000..78fc7b08870c
--- /dev/null
+++ b/web/classic/src/components/table/channel-preparations/AutoPromotionPanel.jsx
@@ -0,0 +1,540 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ Banner,
+ Button,
+ Input,
+ InputNumber,
+ Modal,
+ Select,
+ Space,
+ Spin,
+ Switch,
+ Table,
+ Tag,
+ Typography,
+} from '@douyinfe/semi-ui';
+import { IconPlus, IconRefresh, IconSave } from '@douyinfe/semi-icons';
+import { API, showError, showSuccess, showWarning } from '../../../helpers';
+import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
+
+const SETTING_PREFIX = 'channel_preparation_auto_promotion_setting.';
+const DEFAULT_STRATEGY = 'priority_weighted';
+
+const DEFAULT_SETTINGS = {
+ scheduler_enabled: false,
+ interval_minutes: 10,
+ max_promotions_per_run: 10,
+ rules: [],
+};
+
+function parseBool(value, fallback = false) {
+ if (typeof value === 'boolean') return value;
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ return fallback;
+}
+
+function parseNumber(value, fallback) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function normalizeRule(rule = {}) {
+ return {
+ id: String(
+ rule.id || `rule-${Date.now()}-${Math.random().toString(16).slice(2)}`,
+ ),
+ enabled: parseBool(rule.enabled, true),
+ group: rule.group || 'default',
+ type: Number(rule.type || 14),
+ threshold_usd: parseNumber(rule.threshold_usd, 1),
+ strategy: rule.strategy || DEFAULT_STRATEGY,
+ };
+}
+
+function optionsToSettings(options = []) {
+ const map = {};
+ options.forEach((item) => {
+ map[item.key] = item.value;
+ });
+
+ let rules = [];
+ try {
+ rules = JSON.parse(map[`${SETTING_PREFIX}rules`] || '[]');
+ if (!Array.isArray(rules)) rules = [];
+ } catch (error) {
+ rules = [];
+ }
+
+ return {
+ scheduler_enabled: parseBool(
+ map[`${SETTING_PREFIX}scheduler_enabled`],
+ DEFAULT_SETTINGS.scheduler_enabled,
+ ),
+ interval_minutes: parseNumber(
+ map[`${SETTING_PREFIX}interval_minutes`],
+ DEFAULT_SETTINGS.interval_minutes,
+ ),
+ max_promotions_per_run: parseNumber(
+ map[`${SETTING_PREFIX}max_promotions_per_run`],
+ DEFAULT_SETTINGS.max_promotions_per_run,
+ ),
+ rules: rules.map(normalizeRule),
+ };
+}
+
+function buildOptionUpdates(settings) {
+ return [
+ ['scheduler_enabled', String(!!settings.scheduler_enabled)],
+ ['interval_minutes', String(settings.interval_minutes || 10)],
+ ['max_promotions_per_run', String(settings.max_promotions_per_run || 10)],
+ ['rules', JSON.stringify((settings.rules || []).map(normalizeRule))],
+ ].map(([key, value]) => ({
+ key: `${SETTING_PREFIX}${key}`,
+ value,
+ }));
+}
+
+function formatUSD(value) {
+ const numeric = Number(value || 0);
+ return numeric.toFixed(4);
+}
+
+const AutoPromotionPanel = ({ t, refreshPreparations }) => {
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [running, setRunning] = useState(false);
+ const [canConfigure, setCanConfigure] = useState(true);
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [lastSummary, setLastSummary] = useState(null);
+
+ const updateSettings = useCallback((patch) => {
+ setSettings((prev) => ({ ...prev, ...patch }));
+ }, []);
+
+ const updateRule = useCallback((ruleId, patch) => {
+ setSettings((prev) => ({
+ ...prev,
+ rules: prev.rules.map((rule) =>
+ rule.id === ruleId ? { ...rule, ...patch } : rule,
+ ),
+ }));
+ }, []);
+
+ const addRule = useCallback(() => {
+ setSettings((prev) => ({
+ ...prev,
+ rules: [
+ ...prev.rules,
+ normalizeRule({
+ enabled: true,
+ group: 'default',
+ type: 14,
+ threshold_usd: 1,
+ strategy: DEFAULT_STRATEGY,
+ }),
+ ],
+ }));
+ }, []);
+
+ const removeRule = useCallback((ruleId) => {
+ setSettings((prev) => ({
+ ...prev,
+ rules: prev.rules.filter((rule) => rule.id !== ruleId),
+ }));
+ }, []);
+
+ const validateSettings = useCallback(() => {
+ if (settings.interval_minutes <= 0) {
+ showWarning(t('自动晋升检查间隔必须大于 0'));
+ return false;
+ }
+ if (settings.max_promotions_per_run <= 0) {
+ showWarning(t('每次最大晋升数量必须大于 0'));
+ return false;
+ }
+ for (const rule of settings.rules) {
+ if (!rule.group || !rule.group.trim()) {
+ showWarning(t('自动晋升规则分组不能为空'));
+ return false;
+ }
+ if (!rule.type || Number(rule.type) <= 0) {
+ showWarning(t('自动晋升规则渠道类型无效'));
+ return false;
+ }
+ if (!rule.threshold_usd || Number(rule.threshold_usd) <= 0) {
+ showWarning(t('自动晋升规则阈值必须大于 0'));
+ return false;
+ }
+ }
+ return true;
+ }, [settings, t]);
+
+ const loadSettings = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await API.get('/api/option/', { skipErrorHandler: true });
+ if (!res.data.success) {
+ throw new Error(res.data.message || t('加载自动晋升配置失败'));
+ }
+ setSettings(optionsToSettings(res.data.data || []));
+ setCanConfigure(true);
+ } catch (error) {
+ setCanConfigure(false);
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ useEffect(() => {
+ loadSettings();
+ }, [loadSettings]);
+
+ const saveSettings = useCallback(async () => {
+ if (!validateSettings()) return;
+ setSaving(true);
+ try {
+ const updates = buildOptionUpdates(settings);
+ const orderedUpdates = [
+ ...updates.filter(
+ (item) => item.key !== `${SETTING_PREFIX}scheduler_enabled`,
+ ),
+ ...updates.filter(
+ (item) => item.key === `${SETTING_PREFIX}scheduler_enabled`,
+ ),
+ ];
+ for (const item of orderedUpdates) {
+ const res = await API.put('/api/option/', item);
+ if (!res.data.success) {
+ throw new Error(res.data.message || t('保存自动晋升配置失败'));
+ }
+ }
+ showSuccess(t('自动晋升配置已保存'));
+ await loadSettings();
+ } catch (error) {
+ showError(error.message || t('保存自动晋升配置失败'));
+ } finally {
+ setSaving(false);
+ }
+ }, [loadSettings, settings, t, validateSettings]);
+
+ const runAutoPromotion = useCallback(
+ async (ruleId = '') => {
+ setRunning(true);
+ try {
+ const res = await API.post(
+ '/api/channel/preparations/auto-promotion/run',
+ {
+ rule_id: ruleId,
+ },
+ );
+ if (!res.data.success) {
+ throw new Error(res.data.message || t('执行自动晋升失败'));
+ }
+ const summary = res.data.data;
+ setLastSummary(summary);
+ showSuccess(
+ t('自动晋升检查完成:晋升 {{count}} 个渠道', {
+ count: summary?.total_promoted || 0,
+ }),
+ );
+ refreshPreparations?.();
+ } catch (error) {
+ showError(error.message || t('执行自动晋升失败'));
+ } finally {
+ setRunning(false);
+ }
+ },
+ [refreshPreparations, t],
+ );
+
+ const columns = useMemo(
+ () => [
+ {
+ title: t('启用'),
+ dataIndex: 'enabled',
+ width: 80,
+ render: (_, record) => (
+ updateRule(record.id, { enabled: value })}
+ />
+ ),
+ },
+ {
+ title: t('分组'),
+ dataIndex: 'group',
+ width: 140,
+ render: (_, record) => (
+ updateRule(record.id, { group: value })}
+ />
+ ),
+ },
+ {
+ title: t('渠道类型'),
+ dataIndex: 'type',
+ width: 190,
+ render: (_, record) => (
+
+ ),
+ },
+ {
+ title: t('触发阈值'),
+ dataIndex: 'threshold_usd',
+ width: 140,
+ render: (_, record) => (
+
+ updateRule(record.id, { threshold_usd: Number(value || 0) })
+ }
+ />
+ ),
+ },
+ {
+ title: t('策略'),
+ dataIndex: 'strategy',
+ width: 130,
+ render: () => priority_weighted,
+ },
+ {
+ title: t('操作'),
+ dataIndex: 'operate',
+ width: 150,
+ render: (_, record) => (
+
+ runAutoPromotion(record.id)}
+ >
+ {t('执行')}
+
+ removeRule(record.id)}
+ >
+ {t('删除')}
+
+
+ ),
+ },
+ ],
+ [removeRule, runAutoPromotion, running, t, updateRule],
+ );
+
+ const resultContent = useMemo(() => {
+ if (!lastSummary) return null;
+ return (
+
+
+ {t('本次共晋升 {{count}} 个渠道', {
+ count: lastSummary.total_promoted || 0,
+ })}
+
+ {(lastSummary.rules || []).map((rule) => (
+
+
+ {rule.group} / {rule.type} / {rule.rule_id}
+
+
+ {t('初始容量')}:
+ {formatUSD(rule.initial_capacity?.effective_capacity_usd)} USD,
+ {t('最终容量')}:
+ {formatUSD(rule.final_capacity?.effective_capacity_usd)} USD,
+ {t('阈值')}:{formatUSD(rule.threshold_usd)} USD
+
+
+ {t('参与统计渠道')}:
+ {rule.initial_capacity?.eligible_channel_count || 0},
+ {t('忽略无余额渠道')}:
+ {rule.initial_capacity
+ ?.ignored_non_positive_balance_channel_count || 0}
+
+ {rule.skipped_reason && (
+
+ {t('跳过原因')}:{rule.skipped_reason}
+
+ )}
+ {(rule.failures || []).map((failure) => (
+
+ {failure}
+
+ ))}
+
+ ))}
+
+ );
+ }, [lastSummary, t]);
+
+ if (!canConfigure) {
+ return (
+
+
+ runAutoPromotion('')}
+ >
+ {t('执行自动晋升检查')}
+
+ setLastSummary(null)}
+ footer={null}
+ width={720}
+ >
+ {resultContent}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {t('自动晋升')}
+
+
+ {t(
+ '只统计已启用且余额大于 0 的正式渠道;低于阈值时,从备货池自动晋升余额大于 0 的候选渠道。',
+ )}
+
+
+
+ }
+ loading={loading}
+ onClick={loadSettings}
+ >
+ {t('重新加载')}
+
+ }
+ loading={saving}
+ onClick={saveSettings}
+ >
+ {t('保存自动晋升配置')}
+
+ runAutoPromotion('')}
+ >
+ {t('执行自动晋升检查')}
+
+
+
+
+
+
+
{t('定时自动晋升')}
+
updateSettings({ scheduler_enabled: value })}
+ />
+
+
+
{t('检查间隔')}
+
+ updateSettings({ interval_minutes: Number(value || 10) })
+ }
+ />
+
+
+
{t('每次最大晋升')}
+
+ updateSettings({ max_promotions_per_run: Number(value || 10) })
+ }
+ />
+
+
+
+
0 的渠道,其剩余额度合计 - 已用额度折算;余额 <= 0 的真实渠道会被忽略,余额 <= 0 的候选渠道不会自动晋升。系统不会自动刷新上游余额。',
+ )}
+ className='mb-3'
+ />
+
+
+ {t('自动晋升规则')}
+ } onClick={addRule}>
+ {t('添加规则')}
+
+
+
+
+
+ setLastSummary(null)}
+ footer={null}
+ width={720}
+ >
+ {resultContent}
+
+
+ );
+};
+
+export default AutoPromotionPanel;
diff --git a/web/classic/src/components/table/channel-preparations/index.jsx b/web/classic/src/components/table/channel-preparations/index.jsx
index dc898f9511b9..a67206ead7a4 100644
--- a/web/classic/src/components/table/channel-preparations/index.jsx
+++ b/web/classic/src/components/table/channel-preparations/index.jsx
@@ -10,6 +10,7 @@ import PreparationFilters from './PreparationFilters';
import PreparationTable from './PreparationTable';
import EditPreparationModal from './modals/EditPreparationModal';
import ImportPreparationModal from './modals/ImportPreparationModal';
+import AutoPromotionPanel from './AutoPromotionPanel';
const ChannelPreparationsPage = () => {
const data = useChannelPreparationsData();
@@ -28,6 +29,7 @@ const ChannelPreparationsPage = () => {
onCancel={() => data.setShowImport(false)}
onSubmit={data.importPreparations}
/>
+
Date: Wed, 3 Jun 2026 01:23:42 +0800
Subject: [PATCH 20/38] fix: include existing groups in import dropdowns
---
controller/group.go | 39 +++++++-
model/channel.go | 9 ++
model/channel_preparation.go | 9 ++
.../modals/EditPreparationModal.jsx | 15 +--
.../modals/ImportPreparationModal.jsx | 11 +--
.../channels/modals/BatchImportModal.jsx | 23 ++---
web/classic/src/helpers/utils.jsx | 97 +++++++++++++------
7 files changed, 134 insertions(+), 69 deletions(-)
diff --git a/controller/group.go b/controller/group.go
index 6ba339a3f9bd..8d528d1e5a99 100644
--- a/controller/group.go
+++ b/controller/group.go
@@ -2,7 +2,10 @@ package controller
import (
"net/http"
+ "sort"
+ "strings"
+ "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
@@ -11,11 +14,43 @@ import (
"github.com/gin-gonic/gin"
)
+func addGroupNames(groupSet map[string]bool, groupValues []string) {
+ for _, groupValue := range groupValues {
+ for _, groupName := range strings.Split(groupValue, ",") {
+ groupName = strings.TrimSpace(groupName)
+ if groupName != "" {
+ groupSet[groupName] = true
+ }
+ }
+ }
+}
+
func GetGroups(c *gin.Context) {
- groupNames := make([]string, 0)
+ groupSet := map[string]bool{"default": true}
for groupName := range ratio_setting.GetGroupRatioCopy() {
- groupNames = append(groupNames, groupName)
+ addGroupNames(groupSet, []string{groupName})
+ }
+ channelGroups, err := model.GetDistinctChannelGroups()
+ if err != nil {
+ common.SysError("failed to get channel groups: " + err.Error())
+ } else {
+ addGroupNames(groupSet, channelGroups)
+ }
+ preparationGroups, err := model.GetDistinctChannelPreparationGroups()
+ if err != nil {
+ common.SysError("failed to get channel preparation groups: " + err.Error())
+ } else {
+ addGroupNames(groupSet, preparationGroups)
+ }
+
+ groupNames := make([]string, 0, len(groupSet))
+ for groupName := range groupSet {
+ if groupName != "default" {
+ groupNames = append(groupNames, groupName)
+ }
}
+ sort.Strings(groupNames)
+ groupNames = append([]string{"default"}, groupNames...)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
diff --git a/model/channel.go b/model/channel.go
index 913eefa3ce02..4e56269bf864 100644
--- a/model/channel.go
+++ b/model/channel.go
@@ -1084,6 +1084,15 @@ func CountChannelTags(query *gorm.DB) (int64, error) {
return total, err
}
+func GetDistinctChannelGroups() ([]string, error) {
+ var groups []string
+ err := DB.Model(&Channel{}).
+ Where(commonGroupCol+" IS NOT NULL AND "+commonGroupCol+" != ''").
+ Distinct(commonGroupCol).
+ Pluck(commonGroupCol, &groups).Error
+ return groups, err
+}
+
// Get channels of specified type with pagination
func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) {
var channels []*Channel
diff --git a/model/channel_preparation.go b/model/channel_preparation.go
index 365343787ff5..aec7a17be8b7 100644
--- a/model/channel_preparation.go
+++ b/model/channel_preparation.go
@@ -242,6 +242,15 @@ func applyChannelPreparationFilters(db *gorm.DB, opts ChannelPreparationListOpti
return db
}
+func GetDistinctChannelPreparationGroups() ([]string, error) {
+ var groups []string
+ err := DB.Model(&ChannelPreparation{}).
+ Where(commonGroupCol+" IS NOT NULL AND "+commonGroupCol+" != ''").
+ Distinct(commonGroupCol).
+ Pluck(commonGroupCol, &groups).Error
+ return groups, err
+}
+
func GetChannelPreparations(opts ChannelPreparationListOptions) ([]ChannelPreparation, int64, []ChannelPreparationCountRow, []ChannelPreparationCountRow, error) {
if opts.Page <= 0 {
opts.Page = 1
diff --git a/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx b/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
index d6b33616a0e1..6b9554ac92fc 100644
--- a/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
+++ b/web/classic/src/components/table/channel-preparations/modals/EditPreparationModal.jsx
@@ -13,6 +13,7 @@ import {
getChannelModels,
loadChannelModels,
API,
+ buildGroupOptions,
showError,
} from '../../../../helpers';
@@ -50,16 +51,8 @@ const EditPreparationModal = ({ visible, preparation, onCancel, onSubmit }) => {
loadChannelModels().catch(() => {});
API.get('/api/group/')
.then((res) => {
- if (res.data.success && Array.isArray(res.data.data)) {
- const options = res.data.data.map((item) => ({
- label: item,
- value: item,
- }));
- setGroupOptions(
- options.length > 0
- ? options
- : [{ label: 'default', value: 'default' }],
- );
+ if (res.data.success) {
+ setGroupOptions(buildGroupOptions(res.data.data));
}
})
.catch(() => {});
@@ -188,8 +181,6 @@ const EditPreparationModal = ({ visible, preparation, onCancel, onSubmit }) => {