feat: add model plaza with group-scoped pricing showcase

- public /model-plaza page (standalone + admin-embedded) listing groups
  with discounted effective prices alongside LiteLLM official reference
- faceted platform/group/rate filters: cross-dimension options gray out
  instead of disappearing, platform-tinted chips via accent color-mix
- paid-price columns highlighted with per-platform tint band
- OptionalJWT middleware so anonymous and signed-in users share one route
- admin settings: enable switch, require-auth switch, markdown description
This commit is contained in:
feeeei
2026-07-28 16:19:41 +08:00
parent 2e432173f7
commit 720c405e35
49 changed files with 2612 additions and 11 deletions
+4 -2
View File
@@ -290,6 +290,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -303,13 +304,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
@@ -351,6 +351,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -528,6 +528,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
if before.ModelPlazaRequireAuth != after.ModelPlazaRequireAuth {
changed = append(changed, "model_plaza_require_auth")
}
if before.ModelPlazaDescription != after.ModelPlazaDescription {
changed = append(changed, "model_plaza_description")
}
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
@@ -313,6 +313,11 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
ModelPlazaDescription *string `json:"model_plaza_description"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
@@ -1678,6 +1683,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
}
return previousSettings.ModelPlazaEnabled
}(),
ModelPlazaRequireAuth: func() bool {
if req.ModelPlazaRequireAuth != nil {
return *req.ModelPlazaRequireAuth
}
return previousSettings.ModelPlazaRequireAuth
}(),
ModelPlazaDescription: func() string {
if req.ModelPlazaDescription != nil {
return *req.ModelPlazaDescription
}
return previousSettings.ModelPlazaDescription
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
@@ -2073,6 +2096,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
+8
View File
@@ -290,6 +290,11 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -374,6 +379,9 @@ type PublicSettings struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
+1
View File
@@ -62,6 +62,7 @@ type Handlers struct {
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
ModelPlaza *ModelPlazaHandler
AsyncImage *AsyncImageHandler
BatchImage *BatchImageHandler
}
@@ -0,0 +1,198 @@
package handler
import (
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// ModelPlazaHandler 处理「模型广场」查询。
//
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
channelService *service.ChannelService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
channelService *service.ChannelService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
channelService: channelService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
type modelPlazaOfficialPricing struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price"`
}
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
type modelPlazaResponse struct {
Description string `json:"description"`
Groups []modelPlazaGroup `json:"groups"`
}
// Get 返回模型广场数据。
// GET /api/v1/model-plaza
func (h *ModelPlazaHandler) Get(c *gin.Context) {
if h.settingService == nil {
response.NotFound(c, "Model plaza is not enabled")
return
}
rt := h.settingService.GetModelPlazaRuntime(c.Request.Context())
if !rt.Enabled {
response.NotFound(c, "Model plaza is not enabled")
return
}
subject, authed := middleware.GetAuthSubjectFromContext(c)
if rt.RequireAuth && !authed {
response.Unauthorized(c, "Authentication required")
return
}
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedExclusive map[int64]struct{}
var userRates map[int64]float64
if authed {
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
if err != nil {
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
response.ErrorFrom(c, err)
return
}
userRates, err = h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
if err != nil {
// 专属倍率仅是展示增强,失败降级为分组默认倍率。
slog.Warn("model_plaza_user_rates_failed", "error", err, "user_id", subject.UserID)
userRates = nil
}
}
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
}
response.Success(c, modelPlazaResponse{
Description: rt.Description,
Groups: out,
})
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
func filterPlazaVisibleGroups(
groups []service.PlazaGroup,
allowedExclusive map[int64]struct{},
) []service.PlazaGroup {
visible := make([]service.PlazaGroup, 0, len(groups))
for _, g := range groups {
if g.IsExclusive {
if allowedExclusive == nil {
continue
}
if _, ok := allowedExclusive[g.ID]; !ok {
continue
}
}
visible = append(visible, g)
}
return visible
}
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
models := make([]modelPlazaModel, 0, len(g.Models))
for i := range g.Models {
m := &g.Models[i]
models = append(models, modelPlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
})
}
dto := modelPlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
}
return dto
}
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
if p == nil {
return nil
}
return &modelPlazaOfficialPricing{
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
}
}
@@ -0,0 +1,126 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func plazaGroups() []service.PlazaGroup {
return []service.PlazaGroup{
{ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
{ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
{ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
{ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
}
}
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
allowed := map[int64]struct{}{2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
require.Len(t, visible, 3)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
require.ElementsMatch(t, []int64{1, 2, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
require.Len(t, visible, 2)
}
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
h.Get(c)
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
Pricing: &service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
InputPrice: testPtr(3e-6),
},
OfficialPricing: &service.PlazaOfficialPricing{
InputPrice: testPtr(3e-6),
CacheReadPrice: testPtr(3e-7),
},
}},
}
// 有专属倍率:user_rate_multiplier 序列化输出
dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
raw, err := json.Marshal(dto)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
for _, key := range []string{
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
require.Len(t, models, 1)
model := models[0].(map[string]any)
require.Contains(t, model, "pricing")
require.Contains(t, model, "official_pricing")
official := model["official_pricing"].(map[string]any)
require.Contains(t, official, "input_price")
require.Contains(t, official, "cache_read_price")
_, has1h := official["cache_write_1h_price"]
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
// 无专属倍率:user_rate_multiplier 整个字段省略
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
rawNoRate, err := json.Marshal(dtoNoRate)
require.NoError(t, err)
var decodedNoRate map[string]any
require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
_, hasRate := decodedNoRate["user_rate_multiplier"]
require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
}
func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
require.Nil(t, toModelPlazaOfficialPricing(nil))
}
func testPtr(v float64) *float64 { return &v }
@@ -99,6 +99,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
+3
View File
@@ -183,6 +183,7 @@ func ProvideHandlers(
paymentHandler *PaymentHandler,
paymentWebhookHandler *PaymentWebhookHandler,
availableChannelHandler *AvailableChannelHandler,
modelPlazaHandler *ModelPlazaHandler,
asyncImageHandler *AsyncImageHandler,
batchImageHandler *BatchImageHandler,
_ *service.IdempotencyCoordinator,
@@ -206,6 +207,7 @@ func ProvideHandlers(
Payment: paymentHandler,
PaymentWebhook: paymentWebhookHandler,
AvailableChannel: availableChannelHandler,
ModelPlaza: modelPlazaHandler,
AsyncImage: asyncImageHandler,
BatchImage: batchImageHandler,
}
@@ -230,6 +232,7 @@ var ProviderSet = wire.NewSet(
NewPaymentHandler,
NewPaymentWebhookHandler,
NewAvailableChannelHandler,
NewModelPlazaHandler,
NewAsyncImageHandler,
ProvideBatchImageHandler,
@@ -958,6 +958,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
@@ -1242,6 +1245,9 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
"model_plaza_require_auth": false,
"model_plaza_description": "",
"risk_control_enabled": false,
"cyber_session_block_enabled": false,
"cyber_session_block_ttl_seconds": 3600,
+2 -1
View File
@@ -31,6 +31,7 @@ func ProvideRouter(
cfg *config.Config,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -86,7 +87,7 @@ func ProvideRouter(
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
})
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return SetupRouter(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
}
func configureTrustedProxies(r *gin.Engine, cfg config.ServerConfig) {
@@ -0,0 +1,30 @@
package middleware
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// NewOptionalJWTAuthMiddleware 创建可选 JWT 认证中间件。
//
// 无 Authorization header 时直接放行(匿名,context 中不设置 AuthSubject);
// 带 header 则委托严格 JWT 校验(token 版本 / 用户状态 / 会话绑定),失败返回 401——
// 前端 API client 对 401 会自动走 refresh-token 重试,因此不做静默降级。
func NewOptionalJWTAuthMiddleware(
authService *service.AuthService,
userService *service.UserService,
settingService *service.SettingService,
auditService *service.AuditLogService,
) OptionalJWTAuthMiddleware {
strict := jwtAuth(authService, userService, userService, settingService, auditService)
return OptionalJWTAuthMiddleware(func(c *gin.Context) {
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
c.Next()
return
}
strict(c)
})
}
@@ -0,0 +1,99 @@
//go:build unit
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// newOptionalJWTTestEnv 创建 OptionalJWT 中间件测试环境。
// handler 回写「是否携带 AuthSubject」,便于断言匿名 vs 登录两种路径。
func newOptionalJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
cfg.JWT.AccessTokenExpireMinutes = 60
userRepo := &stubJWTUserRepo{users: users}
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
mw := NewOptionalJWTAuthMiddleware(authSvc, userSvc, nil, nil)
r := gin.New()
r.Use(gin.HandlerFunc(mw))
r.GET("/plaza", func(c *gin.Context) {
subject, authed := GetAuthSubjectFromContext(c)
c.JSON(http.StatusOK, gin.H{"authed": authed, "user_id": subject.UserID})
})
return r, authSvc
}
func TestOptionalJWTAuth_NoHeaderPassesAnonymously(t *testing.T) {
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
func TestOptionalJWTAuth_ValidTokenSetsSubject(t *testing.T) {
user := &service.User{
ID: 7,
Email: "plaza@example.com",
Role: "user",
Status: service.StatusActive,
Concurrency: 5,
TokenVersion: 1,
}
router, authSvc := newOptionalJWTTestEnv(map[int64]*service.User{7: user})
token, err := authSvc.GenerateToken(context.Background(), user)
require.NoError(t, err)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":true`)
require.Contains(t, w.Body.String(), `"user_id":7`)
}
func TestOptionalJWTAuth_InvalidTokenRejected401(t *testing.T) {
// 带了 header 就必须通过严格校验:坏 token 返回 401 而非静默降级为匿名,
// 前端 401 拦截器会走 refresh-token 重试。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestOptionalJWTAuth_BlankHeaderTreatedAsAnonymous(t *testing.T) {
// 空白 header(如 "Authorization: ")按匿名处理,不进入严格校验。
router, _ := newOptionalJWTTestEnv(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
req.Header.Set("Authorization", " ")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), `"authed":false`)
}
@@ -8,6 +8,9 @@ import (
// JWTAuthMiddleware JWT 认证中间件类型
type JWTAuthMiddleware gin.HandlerFunc
// OptionalJWTAuthMiddleware 可选 JWT 认证中间件类型:匿名放行,带 token 严格校验
type OptionalJWTAuthMiddleware gin.HandlerFunc
// AdminAuthMiddleware 管理员认证中间件类型
type AdminAuthMiddleware gin.HandlerFunc
@@ -17,6 +20,7 @@ type APIKeyAuthMiddleware gin.HandlerFunc
// ProviderSet 中间件层的依赖注入
var ProviderSet = wire.NewSet(
NewJWTAuthMiddleware,
NewOptionalJWTAuthMiddleware,
NewAdminAuthMiddleware,
NewAPIKeyAuthMiddleware,
NewAuditLogMiddleware,
+6 -3
View File
@@ -24,6 +24,7 @@ func SetupRouter(
r *gin.Engine,
handlers *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -71,8 +72,8 @@ func SetupRouter(
// Serve embedded frontend with settings injection if available
if web.HasEmbeddedFrontend() {
frontendServer, err := web.NewFrontendServer(settingService)
if err != nil {
frontendServer, err := web.NewFrontendServer(settingService) //nolint:staticcheck // SA4023: the !embed stub always errors; embed builds can return nil
if err != nil { //nolint:staticcheck // SA4023: see above
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
r.Use(web.ServeEmbeddedFrontend())
settingService.SetOnUpdateCallback(refreshFrameOrigins)
@@ -89,7 +90,7 @@ func SetupRouter(
}
// 注册路由
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
registerRoutes(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
return r
}
@@ -99,6 +100,7 @@ func registerRoutes(
r *gin.Engine,
h *handler.Handlers,
jwtAuth middleware2.JWTAuthMiddleware,
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
adminAuth middleware2.AdminAuthMiddleware,
apiKeyAuth middleware2.APIKeyAuthMiddleware,
auditLog middleware2.AuditLogMiddleware,
@@ -124,6 +126,7 @@ func registerRoutes(
// 注册各模块路由
routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService, panelRateLimiter)
routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter)
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
@@ -0,0 +1,30 @@
package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// RegisterModelPlazaRoutes 注册模型广场路由。
//
// 挂 OptionalJWT:匿名可访问(开关与 require_auth 由 handler fail-closed 判定),
// 带 token 则识别用户以展示专属分组与个人倍率。
// BackendModeUserGuard 保证 backend 模式下广场不对非管理员开放(匿名无 role → 403)。
func RegisterModelPlazaRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
optionalJWT middleware.OptionalJWTAuthMiddleware,
settingService *service.SettingService,
panelRateLimiter *middleware.PanelRateLimiter,
) {
plaza := v1.Group("/model-plaza")
plaza.Use(panelRateLimiter.PublicIP())
plaza.Use(gin.HandlerFunc(optionalJWT))
plaza.Use(middleware.BackendModeUserGuard(settingService))
{
plaza.GET("", h.ModelPlaza.Get)
}
}
@@ -980,6 +980,22 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
return keys, nil
}
// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。
//
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
allowed := make(map[int64]struct{}, len(user.AllowedGroups))
for _, id := range user.AllowedGroups {
allowed[id] = struct{}{}
}
return allowed, nil
}
// GetUserGroupRates 获取用户的专属分组倍率配置
// 返回 map[groupID]rateMultiplier
func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) {
+183
View File
@@ -0,0 +1,183 @@
package service
import (
"context"
"fmt"
"sort"
"strings"
)
// PlazaOfficialPricing 模型广场展示用的 LiteLLM 官方参考价(USD per token)。
// 字段为 nil 表示官方数据中该项缺失(0 视为未配置)。
type PlazaOfficialPricing struct {
InputPrice *float64
OutputPrice *float64
CacheWritePrice *float64 // 5m 缓存写入(= LiteLLM cache_creation)
CacheWrite1hPrice *float64 // 1h 缓存写入(LiteLLM cache_creation_above_1hr)
CacheReadPrice *float64
}
// PlazaModel 模型广场中单个模型条目:渠道定价 + 官方参考价。
type PlazaModel struct {
Name string
Platform string
Pricing *ChannelModelPricing
OfficialPricing *PlazaOfficialPricing
}
// PlazaGroup 模型广场中以分组为顶层的条目。
//
// 与 AvailableGroupRef 相比多了 Description 与 Models;Models 来自该分组关联渠道的
// 支持模型(按分组平台隔离,防跨平台泄漏),与「可用渠道」页口径一致。
type PlazaGroup struct {
ID int64
Name string
Description string
Platform string
SubscriptionType string
RateMultiplier float64
PeakRateEnabled bool
PeakStart string
PeakEnd string
PeakRateMultiplier float64
IsExclusive bool
Models []PlazaModel
}
// ListPlazaGroups 返回模型广场数据:每个活跃分组附带其可用模型与定价。
//
// 聚合口径与 ListAvailable 一致(Active 渠道、SupportedModels ∪ 全局定价回落、
// 平台隔离),仅把顶层从渠道换成分组:
// - 渠道按 lower(name) 排序后遍历,保证同名模型去重结果确定;
// - 同分组同名模型「先见者胜」,仅当已存条目无定价而新条目有定价时升级替换;
// - 每个模型附带 LiteLLM 官方参考价(查不到为 nil);
// - 只返回 Models 非空的分组;分组按 RateMultiplier 升序(同倍率按名称),
// 组内模型按名称排序。
//
// 可见性过滤(专属分组)不在此层做,由 handler 按登录态裁剪。
func (s *ChannelService) ListPlazaGroups(ctx context.Context) ([]PlazaGroup, error) {
channels, err := s.repo.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("list channels: %w", err)
}
groups, err := s.groupRepo.ListActive(ctx)
if err != nil {
return nil, fmt.Errorf("list active groups: %w", err)
}
sort.SliceStable(channels, func(i, j int) bool {
return strings.ToLower(channels[i].Name) < strings.ToLower(channels[j].Name)
})
byGroup := make(map[int64]*PlazaGroup, len(groups))
order := make([]int64, 0, len(groups))
for i := range groups {
g := groups[i]
byGroup[g.ID] = &PlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
}
order = append(order, g.ID)
}
// modelIdx[groupID][modelName] = index into byGroup[groupID].Models
modelIdx := make(map[int64]map[string]int, len(groups))
for i := range channels {
ch := &channels[i]
if ch.Status != StatusActive {
continue
}
ch.normalizeBillingModelSource()
supported := ch.SupportedModels()
s.fillGlobalPricingFallback(supported)
for _, gid := range ch.GroupIDs {
pg, ok := byGroup[gid]
if !ok {
continue
}
idx := modelIdx[gid]
if idx == nil {
idx = make(map[string]int, len(supported))
modelIdx[gid] = idx
}
for j := range supported {
m := supported[j]
if m.Platform != pg.Platform {
continue
}
if at, seen := idx[m.Name]; seen {
// 先见者胜;仅当已存条目无定价而新条目有定价时升级。
if pg.Models[at].Pricing == nil && m.Pricing != nil {
pg.Models[at].Pricing = m.Pricing
}
continue
}
idx[m.Name] = len(pg.Models)
pg.Models = append(pg.Models, PlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: m.Pricing,
})
}
}
}
officialMemo := make(map[string]*PlazaOfficialPricing)
out := make([]PlazaGroup, 0, len(order))
for _, gid := range order {
pg := byGroup[gid]
if len(pg.Models) == 0 {
continue
}
sort.SliceStable(pg.Models, func(i, j int) bool { return pg.Models[i].Name < pg.Models[j].Name })
for j := range pg.Models {
pg.Models[j].OfficialPricing = s.lookupOfficialPricing(pg.Models[j].Name, officialMemo)
}
out = append(out, *pg)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].RateMultiplier != out[j].RateMultiplier {
return out[i].RateMultiplier < out[j].RateMultiplier
}
return out[i].Name < out[j].Name
})
return out, nil
}
// lookupOfficialPricing 查询模型的 LiteLLM 官方参考价,带 memo 避免同名模型重复转换。
// pricingService 为 nil(测试场景)或查不到时返回 nil。
func (s *ChannelService) lookupOfficialPricing(modelName string, memo map[string]*PlazaOfficialPricing) *PlazaOfficialPricing {
if s.pricingService == nil {
return nil
}
if cached, ok := memo[modelName]; ok {
return cached
}
var result *PlazaOfficialPricing
if lp := s.pricingService.GetModelPricing(modelName); lp != nil && !lp.TokenPricingAbsent {
result = &PlazaOfficialPricing{
InputPrice: nonZeroPtr(lp.InputCostPerToken),
OutputPrice: nonZeroPtr(lp.OutputCostPerToken),
CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost),
CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr),
CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost),
}
if result.InputPrice == nil && result.OutputPrice == nil &&
result.CacheWritePrice == nil && result.CacheWrite1hPrice == nil && result.CacheReadPrice == nil {
result = nil
}
}
memo[modelName] = result
return result
}
@@ -0,0 +1,194 @@
//go:build unit
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
)
// newPlazaChannelService 构造 ListPlazaGroups 测试用的 ChannelService。
func newPlazaChannelService(channels []Channel, groups []Group, pricing *PricingService) *ChannelService {
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil },
}
svc := NewChannelService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, nil, nil)
svc.pricingService = pricing
return svc
}
func plazaPricedChannel(id int64, name string, groupIDs []int64, platform string, models ...string) Channel {
return Channel{
ID: id,
Name: name,
Status: StatusActive,
GroupIDs: groupIDs,
ModelPricing: []ChannelModelPricing{{
Platform: platform,
Models: models,
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(1.5e-5),
}},
}
}
func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) {
// 两个渠道挂同一分组:模型并入同一 PlazaGroup;无模型的分组不返回。
channels := []Channel{
plazaPricedChannel(1, "chA", []int64{10}, "anthropic", "claude-sonnet"),
plazaPricedChannel(2, "chB", []int64{10}, "anthropic", "claude-opus"),
}
groups := []Group{
{ID: 10, Name: "g-main", Description: "desc", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-empty", Platform: "anthropic", RateMultiplier: 0.5},
}
svc := newPlazaChannelService(channels, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1, "无模型的分组不应返回")
require.Equal(t, int64(10), out[0].ID)
require.Equal(t, "desc", out[0].Description)
require.Len(t, out[0].Models, 2)
// 组内模型按名称排序
require.Equal(t, "claude-opus", out[0].Models[0].Name)
require.Equal(t, "claude-sonnet", out[0].Models[1].Name)
}
func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) {
// 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。
unpriced := Channel{
ID: 1, Name: "alpha", Status: StatusActive, GroupIDs: []int64{10},
// mapping-only → SupportedModels 产出无定价条目
ModelMapping: map[string]map[string]string{
"anthropic": {"claude-sonnet": "claude-sonnet"},
},
}
priced := plazaPricedChannel(2, "beta", []int64{10}, "anthropic", "claude-sonnet")
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
// alpha(无价)按名称序先于 beta(有价):先见者无价,应被有价条目升级。
svc := newPlazaChannelService([]Channel{priced, unpriced}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 1)
require.NotNil(t, out[0].Models[0].Pricing, "无价条目应被有价条目升级")
require.NotNil(t, out[0].Models[0].Pricing.InputPrice)
}
func TestListPlazaGroups_PlatformIsolation(t *testing.T) {
// 渠道同时有 anthropic/openai 定价,anthropic 分组只应看到 anthropic 模型。
ch := Channel{
ID: 1, Name: "multi", Status: StatusActive, GroupIDs: []int64{10, 20},
ModelPricing: []ChannelModelPricing{
{Platform: "anthropic", Models: []string{"claude-sonnet"}, InputPrice: testPtrFloat64(3e-6)},
{Platform: "openai", Models: []string{"gpt-5"}, InputPrice: testPtrFloat64(2e-6)},
},
}
groups := []Group{
{ID: 10, Name: "g-claude", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "g-gpt", Platform: "openai", RateMultiplier: 1},
}
svc := newPlazaChannelService([]Channel{ch}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 2)
byName := map[string][]PlazaModel{}
for _, g := range out {
byName[g.Name] = g.Models
}
require.Len(t, byName["g-claude"], 1)
require.Equal(t, "claude-sonnet", byName["g-claude"][0].Name)
require.Len(t, byName["g-gpt"], 1)
require.Equal(t, "gpt-5", byName["g-gpt"][0].Name)
}
func TestListPlazaGroups_InactiveChannelSkipped(t *testing.T) {
inactive := plazaPricedChannel(1, "off", []int64{10}, "anthropic", "claude-sonnet")
inactive.Status = "inactive"
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
svc := newPlazaChannelService([]Channel{inactive}, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Empty(t, out)
}
func TestListPlazaGroups_SortedByRateMultiplierAsc(t *testing.T) {
channels := []Channel{
plazaPricedChannel(1, "ch", []int64{10, 20, 30}, "anthropic", "claude-sonnet"),
}
groups := []Group{
{ID: 10, Name: "b-standard", Platform: "anthropic", RateMultiplier: 1},
{ID: 20, Name: "a-standard", Platform: "anthropic", RateMultiplier: 1},
{ID: 30, Name: "cheap", Platform: "anthropic", RateMultiplier: 0.5},
}
svc := newPlazaChannelService(channels, groups, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 3)
require.Equal(t, "cheap", out[0].Name, "倍率低者在前")
require.Equal(t, "a-standard", out[1].Name, "同倍率按名称")
require.Equal(t, "b-standard", out[2].Name)
}
func TestListPlazaGroups_OfficialPricingFill(t *testing.T) {
pricingSvc := newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"claude-sonnet": {
Mode: "chat",
InputCostPerToken: 3e-6,
OutputCostPerToken: 1.5e-5,
CacheCreationInputTokenCost: 3.75e-6,
CacheCreationInputTokenCostAbove1hr: 6e-6,
CacheReadInputTokenCost: 3e-7,
},
"token-absent": {Mode: "image_generation", TokenPricingAbsent: true, OutputCostPerImage: 0.04},
})
channels := []Channel{
plazaPricedChannel(1, "ch", []int64{10}, "anthropic", "claude-sonnet", "unknown-model", "token-absent"),
}
groups := []Group{{ID: 10, Name: "g", Platform: "anthropic", RateMultiplier: 1}}
svc := newPlazaChannelService(channels, groups, pricingSvc)
out, err := svc.ListPlazaGroups(context.Background())
require.NoError(t, err)
require.Len(t, out, 1)
require.Len(t, out[0].Models, 3)
byName := map[string]PlazaModel{}
for _, m := range out[0].Models {
byName[m.Name] = m
}
// 命中:填充完整官方价(含 1h 缓存写入)
official := byName["claude-sonnet"].OfficialPricing
require.NotNil(t, official)
require.InDelta(t, 3e-6, *official.InputPrice, 1e-12)
require.InDelta(t, 6e-6, *official.CacheWrite1hPrice, 1e-12)
require.InDelta(t, 3e-7, *official.CacheReadPrice, 1e-12)
// 未命中:nil(GetModelPricing 的 claude 系列模糊匹配对非 claude 名不生效)
require.Nil(t, byName["unknown-model"].OfficialPricing)
// TokenPricingAbsent 条目不作为官方 token 价展示
require.Nil(t, byName["token-absent"].OfficialPricing)
}
func TestListPlazaGroups_RepoErrorsPropagate(t *testing.T) {
sentinel := errors.New("boom")
repo := &mockChannelRepository{
listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel },
}
svc := NewChannelService(repo, &stubGroupRepoForAvailable{}, nil, nil)
out, err := svc.ListPlazaGroups(context.Background())
require.Nil(t, out)
require.ErrorIs(t, err, sentinel)
svc2 := NewChannelService(
&mockChannelRepository{listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, nil }},
&stubGroupRepoForAvailable{listActiveErr: sentinel},
nil, nil,
)
out2, err2 := svc2.ListPlazaGroups(context.Background())
require.Nil(t, out2)
require.ErrorIs(t, err2, sentinel)
}
@@ -385,6 +385,20 @@ const (
// sidebar entry is hidden. Defaults to false (opt-in feature).
SettingKeyAvailableChannelsEnabled = "available_channels_enabled"
// SettingKeyModelPlazaEnabled is a DB-backed soft switch for the Model Plaza page
// (public group/model pricing showcase). When false: the plaza endpoint returns 404
// and the header entry is hidden. Defaults to false (opt-in feature).
SettingKeyModelPlazaEnabled = "model_plaza_enabled"
// SettingKeyModelPlazaRequireAuth controls whether the Model Plaza page requires a
// logged-in user. When false the page is public and anonymous visitors see only
// non-exclusive groups.
SettingKeyModelPlazaRequireAuth = "model_plaza_require_auth"
// SettingKeyModelPlazaDescription stores the Markdown blurb rendered at the top of
// the Model Plaza page (global pricing notes, exchange rate, promotions, ...).
SettingKeyModelPlazaDescription = "model_plaza_description"
// SettingKeyUpstreamBillingProbeSettings stores the global enable switch and interval
// for probing remote Sub2API API-key billing metadata.
SettingKeyUpstreamBillingProbeSettings = "upstream_billing_probe_settings"
+10
View File
@@ -190,6 +190,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false",
// Model plaza feature (default disabled; opt-in, public unless require_auth)
SettingKeyModelPlazaEnabled: "false",
SettingKeyModelPlazaRequireAuth: "false",
SettingKeyModelPlazaDescription: "",
// Affiliate (邀请返利) feature (default disabled; opt-in)
SettingKeyAffiliateEnabled: "false",
SettingKeyAffiliateAdminRechargeEnabled: strconv.FormatBool(AdminRechargeRebateEnabledDefault),
@@ -763,6 +768,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
// Model plaza feature (default: disabled; strict true)
result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true"
result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true"
result.ModelPlazaDescription = settings[SettingKeyModelPlazaDescription]
// Affiliate (邀请返利) feature (default: disabled; strict true)
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
@@ -221,6 +221,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled,
SettingKeyModelPlazaEnabled,
SettingKeyModelPlazaRequireAuth,
SettingKeyAffiliateEnabled,
SettingKeyRiskControlEnabled,
SettingKeyAllowUserViewErrorRequests,
@@ -334,6 +336,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
@@ -416,6 +421,33 @@ func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) Availa
}
}
// ModelPlazaRuntime is the lightweight view of the model-plaza feature consumed
// by the public plaza handler.
type ModelPlazaRuntime struct {
Enabled bool
RequireAuth bool
Description string
}
// GetModelPlazaRuntime reads the model-plaza feature switches directly from the
// settings store. Fail-closed: on error returns Enabled=false, matching the
// opt-in default (unknown ↔ disabled).
func (s *SettingService) GetModelPlazaRuntime(ctx context.Context) ModelPlazaRuntime {
vals, err := s.settingRepo.GetMultiple(ctx, []string{
SettingKeyModelPlazaEnabled,
SettingKeyModelPlazaRequireAuth,
SettingKeyModelPlazaDescription,
})
if err != nil {
return ModelPlazaRuntime{Enabled: false}
}
return ModelPlazaRuntime{
Enabled: vals[SettingKeyModelPlazaEnabled] == "true",
RequireAuth: vals[SettingKeyModelPlazaRequireAuth] == "true",
Description: vals[SettingKeyModelPlazaDescription],
}
}
// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
// directly from the settings store. Fail-closed: on error returns false (opt-in default).
func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
@@ -497,6 +529,8 @@ type PublicSettingsInjectionPayload struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
@@ -563,6 +597,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -395,6 +395,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Available channels feature switch
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
// Model plaza feature switches + description
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription
// Affiliate (邀请返利) feature switch
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
@@ -185,6 +185,11 @@ type SystemSettings struct {
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// Claude Code version check
MinClaudeCodeVersion string
MaxClaudeCodeVersion string
@@ -335,6 +340,10 @@ type PublicSettings struct {
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
// Affiliate (邀请返利) feature toggle
AffiliateEnabled bool `json:"affiliate_enabled"`
+10
View File
@@ -659,6 +659,11 @@ export interface SystemSettings {
// Available Channels feature switch
available_channels_enabled: boolean;
// Model Plaza feature switches + description
model_plaza_enabled: boolean;
model_plaza_require_auth: boolean;
model_plaza_description: string;
// Affiliate (邀请返利) feature switch
affiliate_enabled: boolean;
@@ -930,6 +935,11 @@ export interface UpdateSettingsRequest {
// Available Channels feature switch
available_channels_enabled?: boolean;
// Model Plaza feature switches + description
model_plaza_enabled?: boolean;
model_plaza_require_auth?: boolean;
model_plaza_description?: string;
// Affiliate (邀请返利) feature switch
affiliate_enabled?: boolean;
+62
View File
@@ -0,0 +1,62 @@
/**
* Model Plaza API(公开端点,可匿名访问)
* 以分组为中心的模型价目:分组信息 + 模型渠道定价 + LiteLLM 官方参考价。
* 带 token 请求时后端会额外返回专属分组与用户专属倍率。
*/
import { apiClient } from './client'
import type { UserSupportedModelPricing } from './channels'
/** LiteLLM 官方参考价(USD per token,字段缺失 = 官方数据未覆盖)。 */
export interface PlazaOfficialPricing {
input_price: number | null
output_price: number | null
/** 5m 缓存写入(= LiteLLM cache_creation)。 */
cache_write_price: number | null
/** 1h 缓存写入(LiteLLM cache_creation_above_1hr),多数模型缺失。 */
cache_write_1h_price?: number | null
cache_read_price: number | null
}
export interface PlazaModel {
name: string
platform: string
pricing: UserSupportedModelPricing | null
official_pricing: PlazaOfficialPricing | null
}
export interface ModelPlazaGroup {
id: number
name: string
description: string
platform: string
/** 'standard' | 'subscription' */
subscription_type: string
rate_multiplier: number
/** 登录且管理员为该用户配了专属倍率时返回;生效倍率 = user_rate ?? rate_multiplier。 */
user_rate_multiplier?: number
peak_rate_enabled: boolean
peak_start: string
peak_end: string
peak_rate_multiplier: number
is_exclusive: boolean
models: PlazaModel[]
}
export interface ModelPlazaResponse {
/** 管理员配置的全局价格说明(Markdown)。 */
description: string
groups: ModelPlazaGroup[]
}
/** 获取模型广场数据。开关未启用时后端返回 404。 */
export async function getModelPlaza(options?: { signal?: AbortSignal }): Promise<ModelPlazaResponse> {
const { data } = await apiClient.get<ModelPlazaResponse>('/model-plaza', {
signal: options?.signal
})
return data
}
export const modelPlazaAPI = { getModelPlaza }
export default modelPlazaAPI
@@ -38,6 +38,16 @@
<span class="hidden sm:inline">{{ t('nav.docs') }}</span>
</a>
<!-- Model Plaza Entry -->
<router-link
v-if="user && modelPlazaEnabled"
:to="{ path: '/model-plaza', query: { embedded: '1' } }"
class="hidden items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-dark-400 dark:hover:bg-dark-800 dark:hover:text-white sm:flex"
>
<Icon name="grid" size="sm" />
<span class="hidden sm:inline">{{ t('nav.modelPlaza') }}</span>
</router-link>
<!-- Language Switcher -->
<LocaleSwitcher />
@@ -250,6 +260,7 @@ import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMi
import AnnouncementBell from '@/components/common/AnnouncementBell.vue'
import Icon from '@/components/icons/Icon.vue'
import { sanitizeUrl } from '@/utils/url'
import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
const router = useRouter()
const route = useRoute()
@@ -264,6 +275,7 @@ const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const contactInfo = computed(() => appStore.contactInfo)
const docUrl = computed(() => sanitizeUrl(appStore.docUrl))
const modelPlazaEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.modelPlaza))
const avatarUrl = computed(() => user.value?.avatar_url?.trim() || '')
const availableBalance = computed(() => Number(user.value?.balance || 0))
const frozenBalance = computed(() => Number(user.value?.frozen_balance || 0))
@@ -0,0 +1,195 @@
<template>
<div class="space-y-5">
<!-- 页头(独立形态下展示标题;后台形态 AppHeader 已有页面标题) -->
<div v-if="!embedded">
<h1 class="text-2xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-3xl">{{ t('modelPlaza.title') }}</h1>
<p class="mt-1.5 text-sm text-gray-500 dark:text-dark-400">{{ t('modelPlaza.description') }}</p>
</div>
<!-- 全局价格说明(管理员配置,Markdown) -->
<div
v-if="descriptionHtml"
class="plaza-description rounded-2xl border border-gray-100 bg-white px-5 py-4 text-sm shadow-card dark:border-dark-700/50 dark:bg-dark-800/50"
v-html="descriptionHtml"
></div>
<!-- 未登录提示 -->
<p
v-if="!isAuthenticated"
class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-dark-500"
>
<Icon name="infoCircle" size="xs" class="h-3.5 w-3.5" />
{{ t('modelPlaza.anonymousHint') }}
</p>
<!-- 加载/错误/空 -->
<div v-if="loading" class="flex min-h-[240px] items-center justify-center">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-primary-600/25 border-t-primary-600 dark:border-primary-400/25 dark:border-t-primary-400"></div>
</div>
<div
v-else-if="error"
class="rounded-2xl border border-red-200 bg-red-50 px-5 py-8 text-center text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300"
>
{{ t('modelPlaza.loadFailed') }}
</div>
<template v-else>
<!-- 筛选区:平台 → 分组 → 倍率 -->
<PlazaFilterBar
:platforms="platforms"
:groups="groupOptions"
:rates="rates"
:platform="selectedPlatform"
:group-id="selectedGroupId"
:rate="selectedRate"
:search="searchQuery"
@update:platform="selectedPlatform = $event"
@update:group-id="selectedGroupId = $event"
@update:rate="selectedRate = $event"
@update:search="searchQuery = $event"
/>
<!-- 分组分节的模型清单(默认按生效倍率升序) -->
<div v-if="filteredGroups.length > 0" class="space-y-5">
<PlazaGroupSection v-for="g in filteredGroups" :key="g.id" :group="g" />
</div>
<div
v-else
class="rounded-2xl border border-dashed border-gray-300 px-5 py-12 text-center text-sm text-gray-500 dark:border-dark-600 dark:text-dark-400"
>
{{ searchActive ? t('modelPlaza.noSearchResult') : t('modelPlaza.empty') }}
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import Icon from '@/components/icons/Icon.vue'
import PlazaFilterBar from './PlazaFilterBar.vue'
import PlazaGroupSection from './PlazaGroupSection.vue'
import type { ModelPlazaGroup, ModelPlazaResponse } from '@/api/modelPlaza'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{
response: ModelPlazaResponse | null
loading: boolean
error?: boolean
/** 后台内嵌形态(AppLayout 内):隐藏页头。 */
embedded?: boolean
}>()
const { t } = useI18n()
const authStore = useAuthStore()
const isAuthenticated = computed(() => authStore.isAuthenticated)
const selectedPlatform = ref<string>('all')
const selectedGroupId = ref<number | 'all'>('all')
const selectedRate = ref<number | 'all'>('all')
const searchQuery = ref('')
const searchActive = computed(() => searchQuery.value.trim() !== '')
const descriptionHtml = computed(() => {
const md = props.response?.description?.trim()
if (!md) return ''
return DOMPurify.sanitize(marked.parse(md) as string)
})
/** 生效倍率 = 用户专属倍率 ?? 分组默认倍率。 */
function effectiveRate(g: ModelPlazaGroup): number {
return g.user_rate_multiplier ?? g.rate_multiplier
}
const platforms = computed(() =>
[...new Set((props.response?.groups ?? []).map((g) => g.platform).filter(Boolean))].sort()
)
const groupOptions = computed(() =>
(props.response?.groups ?? []).map((g) => ({
id: g.id,
name: g.name,
platform: g.platform,
rate: effectiveRate(g)
}))
)
/** 全量生效倍率;当前组合下不可用的项由 FilterBar 置灰而非隐藏。 */
const rates = computed(() =>
[...new Set((props.response?.groups ?? []).map(effectiveRate))].sort((a, b) => a - b)
)
/** 数据刷新后选中的倍率可能不复存在,重置为全部。 */
watch(rates, (list) => {
if (selectedRate.value !== 'all' && !list.includes(selectedRate.value)) {
selectedRate.value = 'all'
}
})
const filteredGroups = computed(() => {
let groups = props.response?.groups ?? []
if (selectedPlatform.value !== 'all') {
groups = groups.filter((g) => g.platform === selectedPlatform.value)
}
if (selectedGroupId.value !== 'all') {
groups = groups.filter((g) => g.id === selectedGroupId.value)
}
if (selectedRate.value !== 'all') {
groups = groups.filter((g) => effectiveRate(g) === selectedRate.value)
}
// 模型名搜索:分组内只留命中的模型,整组无命中则隐藏该分组。
const q = searchQuery.value.trim().toLowerCase()
if (q) {
groups = groups
.map((g) => ({ ...g, models: g.models.filter((m) => m.name.toLowerCase().includes(q)) }))
.filter((g) => g.models.length > 0)
}
// 专属倍率会改变生效值,不能只依赖后端按默认倍率的排序。
return [...groups].sort(
(a, b) => effectiveRate(a) - effectiveRate(b) || a.name.localeCompare(b.name)
)
})
</script>
<style scoped>
.plaza-description {
line-height: 1.7;
overflow-wrap: anywhere;
}
.plaza-description :deep(h1),
.plaza-description :deep(h2),
.plaza-description :deep(h3) {
@apply mb-2 mt-3 font-semibold text-gray-900 first:mt-0 dark:text-white;
}
.plaza-description :deep(p) {
@apply mb-2 text-gray-700 last:mb-0 dark:text-dark-200;
}
.plaza-description :deep(a) {
@apply text-primary-600 underline underline-offset-4 hover:text-primary-700 dark:text-primary-300;
}
.plaza-description :deep(ul) {
@apply mb-2 list-disc pl-5;
}
.plaza-description :deep(ol) {
@apply mb-2 list-decimal pl-5;
}
.plaza-description :deep(li) {
@apply mb-0.5 text-gray-700 dark:text-dark-200;
}
.plaza-description :deep(code) {
@apply rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-dark-800;
}
.plaza-description :deep(blockquote) {
@apply my-2 border-l-4 border-gray-300 pl-3 text-gray-600 dark:border-dark-600 dark:text-dark-300;
}
</style>
@@ -0,0 +1,215 @@
<template>
<div class="space-y-3">
<!-- 一级:平台 -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.platformLabel') }}
</span>
<button
v-for="p in ['all', ...platforms]"
:key="`platform-${p}`"
type="button"
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="p === 'all' ? chipClass(platform === 'all') : platform === p ? 'chip-tinted-active' : 'chip-tinted'"
:style="p === 'all' ? undefined : { '--chip-accent': platformAccentColor(p) }"
:disabled="p !== 'all' && !platformEnabled(p)"
@click="$emit('update:platform', p)"
>
<PlatformIcon v-if="p !== 'all'" :platform="p as GroupPlatform" size="xs" />
{{ p === 'all' ? t('modelPlaza.filters.all') : p }}
</button>
</div>
<!-- 二级:分组(按所属平台着色,当前组合下无结果的置灰) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.groupLabel') }}
</span>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="chipClass(groupId === 'all')"
@click="$emit('update:groupId', 'all')"
>
{{ t('modelPlaza.filters.all') }}
</button>
<button
v-for="g in groups"
:key="`group-${g.id}`"
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="groupId === g.id ? 'chip-tinted-active' : 'chip-tinted'"
:style="{ '--chip-accent': platformAccentColor(g.platform) }"
:disabled="!groupEnabled(g)"
@click="$emit('update:groupId', g.id)"
>
{{ g.name }}
</button>
</div>
<!-- 三级:倍率(当前组合下不存在的置灰) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.rateLabel') }}
</span>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition"
:class="chipClass(rate === 'all')"
@click="$emit('update:rate', 'all')"
>
{{ t('modelPlaza.filters.all') }}
</button>
<button
v-for="r in rates"
:key="`rate-${r}`"
type="button"
class="rounded-lg px-3 py-1.5 font-mono text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-40 disabled:grayscale"
:class="chipClass(rate === r)"
:disabled="!rateEnabled(r)"
@click="$emit('update:rate', r)"
>
{{ r }}x
</button>
</div>
<!-- 四级:模型名搜索(纯前端过滤) -->
<div class="flex flex-wrap items-center gap-2">
<span class="w-10 shrink-0 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.filters.modelLabel') }}
</span>
<div class="relative w-full sm:w-72">
<Icon
name="search"
size="sm"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-dark-500"
/>
<input
:value="search"
type="text"
:placeholder="t('modelPlaza.filters.searchPlaceholder')"
class="input rounded-lg py-1.5 pl-9 pr-9"
@input="$emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:text-dark-500 dark:hover:text-gray-300"
@click="$emit('update:search', '')"
>
<Icon name="x" size="xs" class="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
import { platformAccentColor } from '@/utils/platformColors'
import type { GroupPlatform } from '@/types'
const props = defineProps<{
/** 数据中出现的平台(去重排序后)。 */
platforms: string[]
/** 全量分组(含平台与生效倍率),三个维度的置灰联动由此推导。 */
groups: Array<{ id: number; name: string; platform: string; rate: number }>
/** 全量生效倍率去重升序。 */
rates: number[]
platform: string
groupId: number | 'all'
rate: number | 'all'
/** 模型名搜索词(纯前端过滤)。 */
search: string
}>()
defineEmits<{
'update:platform': [value: string]
'update:groupId': [value: number | 'all']
'update:rate': [value: number | 'all']
'update:search': [value: string]
}>()
const { t } = useI18n()
/**
* 三个维度互为约束(faceted):某选项可点 ⟺ 在「其他两维」当前选择下仍有分组命中。
* 「全部」永远可点,作为解除本维约束的出口;可点项组合恒有结果,无需选择修正。
*/
function platformEnabled(p: string): boolean {
return props.groups.some(
(g) =>
g.platform === p &&
(props.groupId === 'all' || g.id === props.groupId) &&
(props.rate === 'all' || g.rate === props.rate)
)
}
function groupEnabled(g: { platform: string; rate: number }): boolean {
return (
(props.platform === 'all' || g.platform === props.platform) &&
(props.rate === 'all' || g.rate === props.rate)
)
}
function rateEnabled(r: number): boolean {
return props.groups.some(
(g) =>
g.rate === r &&
(props.platform === 'all' || g.platform === props.platform) &&
(props.groupId === 'all' || g.id === props.groupId)
)
}
function chipClass(active: boolean): string {
return active
? 'bg-gradient-to-r from-primary-500 to-primary-600 text-white shadow-sm shadow-primary-500/30'
: 'bg-white text-gray-600 ring-1 ring-inset ring-gray-200 enabled:hover:bg-gray-50 enabled:hover:text-gray-900 enabled:hover:ring-gray-300 dark:bg-dark-800/60 dark:text-dark-300 dark:ring-dark-700 dark:enabled:hover:bg-dark-800 dark:enabled:hover:text-white'
}
</script>
<style scoped>
/* 平台/分组 chip 的配色统一从 --chip-accent(平台主色)派生,新增平台无需扩展样式。
激活态与非激活态在模板上互斥挂载,避免选择器优先级互相覆盖。 */
.chip-tinted {
color: var(--chip-accent);
color: color-mix(in srgb, var(--chip-accent) 78%, black);
background-color: color-mix(in srgb, var(--chip-accent) 9%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chip-accent) 25%, transparent);
}
.chip-tinted:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 16%, transparent);
}
.dark .chip-tinted {
color: color-mix(in srgb, var(--chip-accent) 72%, white);
background-color: color-mix(in srgb, var(--chip-accent) 12%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--chip-accent) 30%, transparent);
}
.dark .chip-tinted:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 18%, transparent);
}
.chip-tinted-active {
color: #fff;
background-color: var(--chip-accent);
background-color: color-mix(in srgb, var(--chip-accent) 85%, black);
box-shadow: 0 1px 2px 0 color-mix(in srgb, var(--chip-accent) 35%, transparent);
}
.chip-tinted-active:not(:disabled):hover {
background-color: color-mix(in srgb, var(--chip-accent) 75%, black);
}
.dark .chip-tinted-active {
background-color: color-mix(in srgb, var(--chip-accent) 80%, transparent);
}
.dark .chip-tinted-active:not(:disabled):hover {
background-color: var(--chip-accent);
}
</style>
@@ -0,0 +1,93 @@
<template>
<section
class="rounded-2xl border bg-white shadow-card dark:bg-dark-800/50"
:class="[platformBorderStrongClass(group.platform)]"
>
<!-- 分组头部:名称/平台/倍率徽章/专属/订阅徽章 + 描述 -->
<header class="border-b border-gray-100 px-5 py-4 dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-2">
<GroupBadge
:name="group.name"
:platform="group.platform as GroupPlatform"
:subscription-type="(group.subscription_type || 'standard') as SubscriptionType"
:rate-multiplier="group.rate_multiplier"
:user-rate-multiplier="group.user_rate_multiplier ?? null"
:peak-rate-enabled="group.peak_rate_enabled"
:peak-start="group.peak_start"
:peak-end="group.peak_end"
:peak-rate-multiplier="group.peak_rate_multiplier"
always-show-rate
/>
<span
v-if="group.is_exclusive"
class="inline-flex items-center gap-1 rounded-md bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-600 dark:bg-purple-900/20 dark:text-purple-400"
>
<Icon name="shield" size="xs" class="h-3 w-3" />
{{ t('modelPlaza.badges.exclusive') }}
</span>
<span
v-if="group.subscription_type === 'subscription'"
class="inline-flex items-center rounded-md bg-violet-50 px-2 py-0.5 text-xs font-medium text-violet-600 dark:bg-violet-900/20 dark:text-violet-400"
>
{{ t('modelPlaza.badges.subscription') }}
</span>
</div>
<p v-if="group.description" class="mt-2 text-sm text-gray-500 dark:text-dark-400">
{{ group.description }}
</p>
<p
v-if="peakNote"
class="mt-1.5 inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400"
>
<Icon name="clock" size="xs" class="h-3 w-3" />
{{ peakNote }}
</p>
</header>
<!-- 模型价格表 -->
<div class="px-5">
<PlazaModelPricingTable
v-if="group.models.length > 0"
:models="group.models"
:platform="group.platform"
:rate-multiplier="group.rate_multiplier"
:user-rate-multiplier="group.user_rate_multiplier ?? null"
/>
<p v-else class="py-4 text-center text-sm text-gray-400 dark:text-dark-500">
{{ t('modelPlaza.detail.noModels') }}
</p>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import PlazaModelPricingTable from './PlazaModelPricingTable.vue'
import type { ModelPlazaGroup } from '@/api/modelPlaza'
import type { GroupPlatform, SubscriptionType } from '@/types'
import { platformBorderStrongClass } from '@/utils/platformColors'
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { useAppStore } from '@/stores/app'
const props = defineProps<{
group: ModelPlazaGroup
}>()
const { t } = useI18n()
const appStore = useAppStore()
const peakNote = computed(() => {
if (!hasPeakRate(props.group)) return ''
const window = formatPeakRateWindow(
props.group,
serverTimezoneLabel(appStore.cachedPublicSettings?.server_utc_offset)
)
return t('modelPlaza.detail.peakNote', {
window,
multiplier: props.group.peak_rate_multiplier
})
})
</script>
@@ -0,0 +1,357 @@
<template>
<div class="plaza-pricing-table overflow-x-auto" :style="accentStyle">
<table class="w-full min-w-[860px] table-fixed border-collapse text-sm tabular-nums">
<colgroup>
<col class="w-[22%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[14%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr
class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-dark-400"
>
<th
rowspan="2"
class="border-r border-gray-100 py-2.5 pr-4 text-left align-middle dark:border-dark-700/60"
>
{{ t('modelPlaza.table.model') }}
</th>
<th colspan="3" class="pz-bg pt-2 text-center">
<div class="pz-title border-b pb-2 font-semibold">
{{ t('modelPlaza.table.paidPrice') }}
<span class="pz-unit ml-1 normal-case font-normal">{{ t('modelPlaza.table.unitPerMillion') }}</span>
</div>
</th>
<th
colspan="3"
class="border-l border-gray-100 pt-2 text-center dark:border-dark-700/60"
>
<div class="border-b border-gray-200 pb-2 text-gray-400 dark:border-dark-600 dark:text-dark-500">
{{ t('modelPlaza.table.officialPrice') }}
<span class="ml-1 normal-case font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.unitPerMillion') }}</span>
</div>
</th>
<th
rowspan="2"
class="border-l border-gray-100 py-2.5 pl-3 pr-1 text-right align-middle dark:border-dark-700/60"
>
{{ t('modelPlaza.table.rate') }}
</th>
</tr>
<tr
class="border-b border-gray-200 text-left text-[11px] font-medium uppercase leading-4 tracking-wide text-gray-400 dark:border-dark-700 dark:text-dark-500"
>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.input') }}</th>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.output') }}</th>
<th class="pz-bg px-3 py-2 font-medium">{{ t('modelPlaza.table.cache') }}</th>
<th class="border-l border-gray-100 px-3 py-2 font-medium dark:border-dark-700/60">
{{ t('modelPlaza.table.input') }}
</th>
<th class="px-3 py-2 font-medium">{{ t('modelPlaza.table.output') }}</th>
<th class="px-3 py-2 font-medium">{{ t('modelPlaza.table.cache') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="m in sortedModels"
:key="m.name"
class="border-b border-gray-100 transition-colors last:border-b-0 hover:bg-gray-50/70 dark:border-dark-800 dark:hover:bg-dark-800/50"
>
<!-- 模型名 + 非 token 计费模式徽章 -->
<td class="border-r border-gray-100 py-2.5 pr-4 align-middle dark:border-dark-700/60">
<div class="flex flex-wrap items-center gap-1.5">
<span class="font-medium text-gray-900 dark:text-white">{{ m.name }}</span>
<span
v-if="billingMode(m) !== BILLING_MODE_TOKEN"
class="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-dark-700/70 dark:text-dark-300"
>
{{ billingModeLabel(m) }}
</span>
</div>
</td>
<!-- token 计费:输入 / 输出(阶梯内联)/ 缓存(写/读) -->
<template v-if="billingMode(m) === BILLING_MODE_TOKEN">
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
<div
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.input_price) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.input_price) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle font-mono font-semibold text-gray-900 dark:text-gray-50">
<template v-if="tokenIntervals(m).length">
<div
v-for="(iv, idx) in tokenIntervals(m)"
:key="idx"
class="whitespace-nowrap text-xs leading-5"
>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidPerMillion(iv.output_price) }}
</div>
</template>
<template v-else>{{ paidPerMillion(m.pricing?.output_price) }}</template>
</td>
<td class="pz-cell px-3 py-2.5 align-middle">
<div
v-if="hasCachePricing(m)"
class="space-y-0.5 font-mono text-xs text-gray-800 dark:text-gray-200"
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ paidPerMillion(m.pricing?.cache_write_price) }}
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ paidPerMillion(m.pricing?.cache_read_price) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
</template>
<!-- 按次 / 按图片计费:实付区整体合并,阶梯芯片或单一按次价 -->
<template v-else>
<td colspan="3" class="pz-cell px-3 py-2.5 align-middle">
<div
v-if="requestIntervals(m).length"
class="flex flex-wrap items-center gap-1.5"
>
<span
v-for="(iv, idx) in requestIntervals(m)"
:key="idx"
class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-800 dark:bg-dark-700/60 dark:text-gray-200"
>
<span class="font-sans text-gray-400 dark:text-dark-500">{{ tierLabel(iv) }}</span>
{{ paidRequestPrice(iv.per_request_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</span>
</div>
<template v-else-if="m.pricing?.per_request_price != null">
<span class="font-mono font-semibold text-gray-900 dark:text-gray-50">
{{ paidRequestPrice(m.pricing.per_request_price) }}
</span>
<span class="ml-1 text-xs text-gray-400 dark:text-dark-500">{{ perUnitSuffix(m) }}</span>
</template>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
</template>
<!-- 官方价格(LiteLLM 参考价,不乘倍率) -->
<td
class="border-l border-gray-100 px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:border-dark-700/60 dark:text-dark-400"
>
{{ official(m.official_pricing?.input_price) }}
</td>
<td class="px-3 py-2.5 align-middle font-mono text-xs text-gray-500 dark:text-dark-400">
{{ official(m.official_pricing?.output_price) }}
</td>
<td class="px-3 py-2.5 align-middle">
<div
v-if="m.official_pricing && hasOfficialCache(m.official_pricing)"
class="space-y-0.5 font-mono text-xs text-gray-500 dark:text-dark-400"
>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheWrite') }}</span>
{{ official(m.official_pricing.cache_write_price)
}}<template v-if="m.official_pricing.cache_write_1h_price != null"
><span class="font-sans text-gray-400 dark:text-dark-500"> (1h </span>{{ official(m.official_pricing.cache_write_1h_price)
}}<span class="font-sans text-gray-400 dark:text-dark-500">)</span></template
>
</div>
<div>
<span class="mr-1 font-sans font-normal text-gray-400 dark:text-dark-500">{{ t('modelPlaza.table.cacheRead') }}</span>
{{ official(m.official_pricing.cache_read_price) }}
</div>
</div>
<span v-else class="text-gray-400 dark:text-dark-500">-</span>
</td>
<!-- 折扣倍率(专属倍率划线展示原倍率) -->
<td
class="border-l border-gray-100 py-2.5 pl-3 pr-1 text-right align-middle font-mono text-xs dark:border-dark-700/60"
>
<template v-if="hasCustomRate">
<span class="mr-1 text-gray-400 line-through dark:text-dark-500">{{ rateMultiplier }}x</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ effectiveRate }}x</span>
</template>
<span v-else class="font-bold text-gray-700 dark:text-gray-300">{{ effectiveRate }}x</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatScaled } from '@/utils/pricing'
import { platformAccentColor } from '@/utils/platformColors'
import {
BILLING_MODE_TOKEN,
BILLING_MODE_IMAGE,
type BillingMode
} from '@/constants/channel'
import type { PlazaModel } from '@/api/modelPlaza'
import type { UserPricingInterval } from '@/api/channels'
const props = defineProps<{
models: PlazaModel[]
/** 分组平台;实付分区底色随平台着色,未知平台回退品牌青。 */
platform?: string
/** 分组默认倍率。 */
rateMultiplier: number
/** 用户专属倍率;与默认不同,实付价按此计算并划线展示原倍率。 */
userRateMultiplier?: number | null
}>()
const { t } = useI18n()
/** 实付分区只从平台拿一个主色,浅底/标题/下划线全部由 scoped CSS 用 color-mix 派生。 */
const accentStyle = computed(() => ({ '--plaza-accent': platformAccentColor(props.platform ?? '') }))
const PER_MILLION = 1_000_000
/** 展示顺序:官方输出价从高到低;无官方价的排最后;同价按名称升序。 */
const sortedModels = computed(() => {
return [...props.models].sort((a, b) => {
const pa = a.official_pricing?.output_price ?? null
const pb = b.official_pricing?.output_price ?? null
if (pa != null && pb != null && pa !== pb) return pb - pa
if (pa != null && pb == null) return -1
if (pa == null && pb != null) return 1
return a.name.localeCompare(b.name)
})
})
const effectiveRate = computed(() => props.userRateMultiplier ?? props.rateMultiplier)
const hasCustomRate = computed(
() => props.userRateMultiplier != null && props.userRateMultiplier !== props.rateMultiplier
)
function billingMode(m: PlazaModel): BillingMode {
return (m.pricing?.billing_mode || BILLING_MODE_TOKEN) as BillingMode
}
function billingModeLabel(m: PlazaModel): string {
return billingMode(m) === BILLING_MODE_IMAGE
? t('modelPlaza.table.perImage')
: t('modelPlaza.table.perRequest')
}
/** 价格统一保底 2 位小数,更长的有效小数原样保留。 */
const MIN_DECIMALS = 2
/** 实付价 = 渠道单价 × 生效倍率,按 $/1M token 展示。 */
function paidPerMillion(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, PER_MILLION, MIN_DECIMALS)
}
/** 按次 / 按图片单价(乘生效倍率,不换算 1M)。 */
function paidRequestPrice(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value * effectiveRate.value, 1, MIN_DECIMALS)
}
/** 官方参考价不乘倍率。 */
function official(value: number | null | undefined): string {
if (value == null) return '-'
return formatScaled(value, PER_MILLION, MIN_DECIMALS)
}
/** 非 token 计费的单位后缀:按图片 → “/ 张”,按次 → “/ 次”。 */
function perUnitSuffix(m: PlazaModel): string {
return billingMode(m) === BILLING_MODE_IMAGE
? t('modelPlaza.table.perUnitImage')
: t('modelPlaza.table.perUnitRequest')
}
function hasCachePricing(m: PlazaModel): boolean {
return m.pricing?.cache_write_price != null || m.pricing?.cache_read_price != null
}
function hasOfficialCache(o: NonNullable<PlazaModel['official_pricing']>): boolean {
return o.cache_write_price != null || o.cache_read_price != null || o.cache_write_1h_price != null
}
/** token 模式的阶梯定价(内联进输入/输出列)。 */
function tokenIntervals(m: PlazaModel): UserPricingInterval[] {
return m.pricing?.intervals ?? []
}
/** 按次/按图模式的阶梯定价(仅保留配了按次价的档位)。 */
function requestIntervals(m: PlazaModel): UserPricingInterval[] {
return (m.pricing?.intervals ?? []).filter((iv) => iv.per_request_price != null)
}
/** 档位标签:优先管理员配置的 tier_label,否则按 token 区间生成(≤200K / >200K / 200K–1M)。 */
function tierLabel(iv: UserPricingInterval): string {
if (iv.tier_label) return iv.tier_label
const { min_tokens: min, max_tokens: max } = iv
if (max == null) return `>${formatTokenCount(min)}`
if (min === 0) return `≤${formatTokenCount(max)}`
return `${formatTokenCount(min)}–${formatTokenCount(max)}`
}
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${trimZero(n / 1_000_000)}M`
if (n >= 1_000) return `${trimZero(n / 1_000)}K`
return String(n)
}
function trimZero(n: number): string {
return String(Math.round(n * 100) / 100)
}
</script>
<style scoped>
/* 实付分区配色统一从 --plaza-accent(平台主色)派生,新增平台无需扩展样式 */
.plaza-pricing-table {
--pz-title: color-mix(in srgb, var(--plaza-accent) 88%, black);
--pz-bg: color-mix(in srgb, var(--plaza-accent) 7%, transparent);
--pz-bg-hover: color-mix(in srgb, var(--plaza-accent) 13%, transparent);
}
.dark .plaza-pricing-table {
--pz-title: color-mix(in srgb, var(--plaza-accent) 70%, white);
--pz-bg: color-mix(in srgb, var(--plaza-accent) 6%, transparent);
--pz-bg-hover: color-mix(in srgb, var(--plaza-accent) 10%, transparent);
}
.pz-bg,
.pz-cell {
background-color: var(--pz-bg);
}
.pz-cell {
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
tbody tr:hover .pz-cell {
background-color: var(--pz-bg-hover);
}
.pz-title {
/* color-mix 不可用的老浏览器回退为平台原色 */
color: var(--plaza-accent);
color: var(--pz-title);
border-color: color-mix(in srgb, var(--pz-title) 30%, transparent);
}
.pz-unit {
color: color-mix(in srgb, var(--pz-title) 62%, transparent);
}
</style>
@@ -0,0 +1,61 @@
<template>
<header
class="glass sticky top-0 z-30 border-b border-gray-200/50 dark:border-dark-700/50"
>
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
<!-- 左:站点 logo + 名称 -->
<div class="flex min-w-0 items-center gap-3">
<template v-if="settings">
<span
class="flex h-9 w-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-dark-800 dark:ring-dark-700"
>
<img :src="siteLogo || '/logo.svg'" alt="Logo" class="h-full w-full object-contain" />
</span>
<span class="truncate text-base font-semibold text-gray-950 dark:text-white">
{{ siteName }}
</span>
</template>
<template v-else>
<span class="h-9 w-9 flex-shrink-0 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
<span class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-dark-700" aria-hidden="true"></span>
</template>
</div>
<!-- 右:登录 / 回到后台 -->
<RouterLink
v-if="isAuthenticated"
:to="backTarget"
class="inline-flex flex-shrink-0 items-center justify-center gap-1.5 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-md shadow-primary-500/25 transition-all duration-200 hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30 active:scale-[0.98] dark:shadow-primary-500/20"
>
{{ t('modelPlaza.nav.backToDashboard') }}
</RouterLink>
<RouterLink
v-else
:to="{ path: '/login', query: { redirect: '/model-plaza' } }"
class="inline-flex flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 px-4 py-2 text-sm font-semibold text-white shadow-md shadow-primary-500/25 transition-all duration-200 hover:from-primary-600 hover:to-primary-700 hover:shadow-lg hover:shadow-primary-500/30 active:scale-[0.98] dark:shadow-primary-500/20"
>
{{ t('modelPlaza.nav.login') }}
</RouterLink>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { sanitizeUrl } from '@/utils/url'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const appStore = useAppStore()
const authStore = useAuthStore()
const settings = computed(() => appStore.cachedPublicSettings)
const siteName = computed(() => settings.value?.site_name || 'Sub2API')
const siteLogo = computed(() =>
sanitizeUrl(settings.value?.site_logo || '', { allowRelative: true, allowDataUrl: true })
)
const isAuthenticated = computed(() => authStore.isAuthenticated)
const backTarget = computed(() => (authStore.isAdmin ? '/admin/dashboard' : '/dashboard'))
</script>
@@ -0,0 +1,257 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import PlazaModelPricingTable from '../PlazaModelPricingTable.vue'
import type { PlazaModel } from '@/api/modelPlaza'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
function tokenModel(overrides: Partial<PlazaModel> = {}): PlazaModel {
return {
name: 'claude-sonnet',
platform: 'anthropic',
pricing: {
billing_mode: 'token',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: 3.75e-6,
cache_read_price: 3e-7,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
},
official_pricing: {
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: 3.75e-6,
cache_write_1h_price: 6e-6,
cache_read_price: 3e-7
},
...overrides
}
}
function mountTable(models: PlazaModel[], rateMultiplier: number, userRateMultiplier?: number | null) {
return mount(PlazaModelPricingTable, {
props: { models, rateMultiplier, userRateMultiplier: userRateMultiplier ?? null }
})
}
describe('PlazaModelPricingTable', () => {
it('倍率为 1 时展示渠道单价原值($/1M),价格保底 2 位小数', () => {
const wrapper = mountTable([tokenModel()], 1)
const text = wrapper.text()
expect(text).toContain('$3.00')
expect(text).toContain('$15.00')
// 缓存写 / 读(超过 2 位小数原样保留)
expect(text).toContain('$3.75')
expect(text).toContain('$0.30')
// 倍率列
expect(text).toContain('1x')
})
it('倍率 ≠ 1 时价格列为折后实付价,官方价列保持原价', () => {
const wrapper = mountTable([tokenModel()], 0.5)
const text = wrapper.text()
// 实付 = 3 × 0.5 / 15 × 0.5
expect(text).toContain('$1.50')
expect(text).toContain('$7.50')
// 官方价原值仍在(官方列不乘倍率)
expect(text).toContain('$3.00')
expect(text).toContain('$15.00')
expect(text).toContain('0.5x')
})
it('用户专属倍率覆盖分组倍率,并划线展示原倍率', () => {
const wrapper = mountTable([tokenModel()], 1, 0.8)
const text = wrapper.text()
// 实付按 0.8:3 × 0.8 = 2.4
expect(text).toContain('$2.40')
expect(text).toContain('$12.00')
// 倍率列:原倍率划线 + 专属倍率
const struck = wrapper.find('td .line-through')
expect(struck.exists()).toBe(true)
expect(struck.text()).toBe('1x')
expect(text).toContain('0.8x')
})
it('模型按官方输出价从高到低排序,无官方价的排最后', () => {
const expensive = tokenModel({
name: 'model-expensive',
official_pricing: {
input_price: 1e-5,
output_price: 7.5e-5,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null
}
})
const cheap = tokenModel({
name: 'model-cheap',
official_pricing: {
input_price: 1e-6,
output_price: 5e-6,
cache_write_price: null,
cache_write_1h_price: null,
cache_read_price: null
}
})
const noOfficial = tokenModel({ name: 'model-no-official', official_pricing: null })
const wrapper = mountTable([cheap, noOfficial, expensive], 1)
const names = wrapper.findAll('tbody tr').map((tr) => tr.find('td').text())
expect(names).toEqual(['model-expensive', 'model-cheap', 'model-no-official'])
})
it('两级表头:实付区与官方区各拆输入/输出/缓存列', () => {
const wrapper = mountTable([tokenModel()], 1)
const text = wrapper.text()
expect(text).toContain('modelPlaza.table.paidPrice')
expect(text).toContain('modelPlaza.table.officialPrice')
// token 行:模型 + 实付 3 列 + 官方 3 列 + 倍率
expect(wrapper.findAll('tbody td')).toHaveLength(8)
})
it('官方价包含 1h 缓存写入价;official_pricing 为 null 时官方三列显示 -', () => {
const withOfficial = mountTable([tokenModel()], 1)
expect(withOfficial.text()).toContain('$6.00')
expect(withOfficial.text()).toContain('(1h')
const withoutOfficial = mountTable([tokenModel({ official_pricing: null })], 1)
const cells = withoutOfficial.findAll('tbody td')
// 官方 输入/输出/缓存 三列均为 -
expect(cells[4].text().trim()).toBe('-')
expect(cells[5].text().trim()).toBe('-')
expect(cells[6].text().trim()).toBe('-')
})
it('per_request 模型按单次价 × 倍率展示,官方价列显示 -', () => {
const model = tokenModel({
name: 'search-tool',
pricing: {
billing_mode: 'per_request',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: 0.04,
intervals: []
},
official_pricing: null
})
const wrapper = mountTable([model], 0.5)
const text = wrapper.text()
// 0.04 × 0.5 = 0.02,scale=1
expect(text).toContain('$0.02')
expect(text).toContain('modelPlaza.table.perRequest')
// 单位后缀跟在价格后(按次 → / 次)
expect(text).toContain('modelPlaza.table.perUnitRequest')
})
it('token 模型阶梯定价内联进输入/输出列,按倍率折算', () => {
const model = tokenModel({
pricing: {
billing_mode: 'token',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: [
{
min_tokens: 0,
max_tokens: 200000,
tier_label: '',
input_price: 3e-6,
output_price: 1.5e-5,
cache_write_price: null,
cache_read_price: null,
per_request_price: null
},
{
min_tokens: 200000,
max_tokens: null,
tier_label: '',
input_price: 6e-6,
output_price: 3e-5,
cache_write_price: null,
cache_read_price: null,
per_request_price: null
}
]
}
})
const wrapper = mountTable([model], 0.5)
const text = wrapper.text()
// 区间标签按 token 数生成
expect(text).toContain('≤200K')
expect(text).toContain('>200K')
// 折后:输入 1.5 / 3,输出 7.5 / 15
expect(text).toContain('$1.50')
expect(text).toContain('$7.50')
expect(text).toContain('$15.00')
})
it('按图模型主行展示阶梯芯片,不把 image_output_price(每 token)当按次价', () => {
const model = tokenModel({
name: 'gpt-image-2',
pricing: {
billing_mode: 'image',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
// 每 token 图片输出价:不应被当作按次单价展示
image_output_price: 3e-5,
per_request_price: null,
intervals: [
{
min_tokens: 0,
max_tokens: null,
tier_label: '1K',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: 0.01
},
{
min_tokens: 0,
max_tokens: null,
tier_label: '2K',
input_price: null,
output_price: null,
cache_write_price: null,
cache_read_price: null,
per_request_price: 0.02
}
]
},
official_pricing: null
})
const wrapper = mountTable([model], 0.1)
const text = wrapper.text()
expect(text).toContain('modelPlaza.table.perImage')
// 芯片:1K $0.001 / 2K $0.002,单位后缀内嵌(按图 → / 张)
expect(text).toContain('1K')
expect(text).toContain('$0.001')
expect(text).toContain('2K')
expect(text).toContain('$0.002')
expect(text).toContain('modelPlaza.table.perUnitImage')
// 旧 bug:image_output_price × 0.1 = 0.000003 被当按次价
expect(text).not.toContain('$0.000003')
})
})
@@ -30,6 +30,16 @@ export default {
enabled: 'Enable Available Channels',
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
},
modelPlaza: {
title: 'Model Plaza',
description: 'A public page showcasing available models and pricing by group. Disabled by default.',
enabled: 'Enable Model Plaza',
enabledHint: 'When enabled, an entry appears in the header and the page is reachable at /model-plaza.',
requireAuth: 'Require sign-in to access',
requireAuthHint: 'When on, anonymous visitors are redirected to the login page; when off, the page is public and anonymous visitors only see non-exclusive groups.',
priceDescription: 'Pricing notes (Markdown)',
priceDescriptionHint: 'Rendered at the top of the plaza page. Use it for billing rules, exchange rates, promotions, etc.',
},
riskControl: {
title: 'Risk Control',
description: 'Enable the content moderation menu and gateway audit entry point. Disabled by default.',
+1
View File
@@ -166,6 +166,7 @@ export default {
groups: 'Groups',
channels: 'Channels',
availableChannels: 'Available Channels',
modelPlaza: 'Model Plaza',
subscriptions: 'Subscriptions',
accounts: 'Accounts',
proxies: 'Proxies',
+48
View File
@@ -520,6 +520,54 @@ export default {
}
},
// Model Plaza (public group/model pricing showcase)
modelPlaza: {
title: 'Model Plaza',
description: 'Browse available models and pricing by group',
loading: 'Loading...',
empty: 'No groups to display',
loadFailed: 'Failed to load model plaza',
noSearchResult: 'No matching models',
anonymousHint: 'Sign in to see your exclusive groups and personal rates',
filters: {
platformLabel: 'Platform',
groupLabel: 'Group',
rateLabel: 'Rate',
modelLabel: 'Model',
searchPlaceholder: 'Search models',
all: 'All'
},
badges: {
exclusive: 'Exclusive',
subscription: 'Subscription'
},
detail: {
noModels: 'No models configured for this group',
noPricing: 'Pricing not configured',
peakNote: 'Peak hours {window}: billing rate ×{multiplier}'
},
table: {
model: 'Model',
input: 'Input',
output: 'Output',
cache: 'Cache',
cacheWrite: 'Write',
cacheRead: 'Read',
paidPrice: 'Your Price (Discounted)',
officialPrice: 'Official Price',
rate: 'Rate',
unitPerMillion: '$ / 1M tokens',
perUnitRequest: '/ request',
perUnitImage: '/ image',
perRequest: 'Per request',
perImage: 'Per image'
},
nav: {
login: 'Sign In',
backToDashboard: 'Back to Console'
}
},
affiliate: {
title: 'Affiliate Rebates',
description: 'Invite new users and convert your rebate quota into account balance',
@@ -30,6 +30,16 @@ export default {
enabled: '启用可用渠道',
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
},
modelPlaza: {
title: '模型广场',
description: '以分组为单位向访客展示可用模型与价格的公开页面。默认关闭。',
enabled: '启用模型广场',
enabledHint: '开启后顶栏显示入口,页面可通过 /model-plaza 独立访问。',
requireAuth: '需要登录才可访问',
requireAuthHint: '开启后未登录访问将跳转登录页;关闭则公开可见,匿名访客仅展示非专属分组。',
priceDescription: '价格说明(Markdown)',
priceDescriptionHint: '展示在模型广场页面顶部,可用于说明计费规则、汇率、优惠活动等。',
},
riskControl: {
title: '风控中心',
description: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
+1
View File
@@ -166,6 +166,7 @@ export default {
groups: '分组管理',
channels: '渠道管理',
availableChannels: '可用渠道',
modelPlaza: '模型广场',
subscriptions: '订阅管理',
accounts: '账号管理',
proxies: 'IP管理',
+48
View File
@@ -525,6 +525,54 @@ export default {
}
},
// Model Plaza (public group/model pricing showcase)
modelPlaza: {
title: '模型广场',
description: '按分组浏览可用模型与价格',
loading: '加载中...',
empty: '暂无可展示的分组',
loadFailed: '加载模型广场失败',
noSearchResult: '没有匹配的模型',
anonymousHint: '登录后可查看你的专属分组与专属倍率',
filters: {
platformLabel: '平台',
groupLabel: '分组',
rateLabel: '倍率',
modelLabel: '模型',
searchPlaceholder: '搜索模型名称',
all: '全部'
},
badges: {
exclusive: '专属分组',
subscription: '订阅'
},
detail: {
noModels: '该分组暂未配置模型',
noPricing: '未配置定价',
peakNote: '高峰时段 {window} 计费倍率 ×{multiplier}'
},
table: {
model: '模型',
input: '输入',
output: '输出',
cache: '缓存',
cacheWrite: '写入',
cacheRead: '读取',
paidPrice: '实付价格(折后)',
officialPrice: '官方价格',
rate: '折扣倍率',
unitPerMillion: '$ / 1M token',
perUnitRequest: '/ 次',
perUnitImage: '/ 张',
perRequest: '按次计费',
perImage: '按图片计费'
},
nav: {
login: '登录',
backToDashboard: '回到后台'
}
},
affiliate: {
title: '邀请返利',
description: '邀请新用户注册,并将返利额度转入账户余额',
+41
View File
@@ -175,6 +175,16 @@ const routes: RouteRecordRaw[] = [
title: 'Legal Document'
}
},
{
path: '/model-plaza',
name: 'ModelPlaza',
component: () => import('@/views/ModelPlazaView.vue'),
meta: {
requiresAuth: false,
title: 'Model Plaza',
titleKey: 'modelPlaza.title'
}
},
// ==================== User Routes ====================
{
@@ -807,6 +817,37 @@ router.beforeEach(async (to, _from, next) => {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
// Model Plaza:公开路由但受「启用开关 + 可选强制登录」双重控制(后端同口径 fail-closed)
if (to.path === '/model-plaza') {
if (!appStore.publicSettingsLoaded) {
try {
await appStore.fetchPublicSettings()
} catch (error) {
console.warn('Failed to load public settings in route guard', error)
}
}
const plazaSettings = appStore.cachedPublicSettings
// 仅在设置成功加载且明确为 false 时拦截(瞬时加载失败视为未知,由后端 404 兜底)
if (appStore.publicSettingsLoaded && plazaSettings?.model_plaza_enabled === false) {
next(
authStore.isAuthenticated
? authStore.isAdmin
? '/admin/dashboard'
: '/dashboard'
: '/home'
)
return
}
if (plazaSettings?.model_plaza_require_auth === true && !authStore.isAuthenticated) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// Backend mode:登录的非管理员也不可见(匿名由下方公共拦截处理,广场不在白名单)
if (appStore.backendModeEnabled && authStore.isAuthenticated && !authStore.isAdmin) {
next('/login')
return
}
}
// Backend mode: block public pages for unauthenticated users (except login, key-usage, setup)
if (appStore.backendModeEnabled && !authStore.isAuthenticated) {
const isAllowed = isBackendModePublicRouteAllowed(to.path, authStore.hasPendingAuthSession)
@@ -54,6 +54,8 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
+2
View File
@@ -366,6 +366,8 @@ export const useAppStore = defineStore('app', () => {
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
model_plaza_enabled: false,
model_plaza_require_auth: false,
risk_control_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
+2
View File
@@ -239,6 +239,8 @@ export interface PublicSettings {
channel_monitor_enabled: boolean
channel_monitor_default_interval_seconds: number
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
service_quota_enabled: boolean
affiliate_enabled: boolean
allow_user_view_error_requests?: boolean
+5
View File
@@ -104,6 +104,11 @@ export const FeatureFlags = {
mode: 'opt-in',
label: 'Available Channels',
}),
modelPlaza: defineFlag({
key: 'model_plaza_enabled',
mode: 'opt-in',
label: 'Model Plaza',
}),
payment: defineFlag({
key: 'payment_enabled',
mode: 'opt-out',
+31
View File
@@ -39,6 +39,29 @@ const BORDER: Record<Platform, string> = {
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
// ── Border strong (higher-contrast platform tint, e.g. plaza group cards) ──
const BORDER_STRONG: Record<Platform, string> = {
anthropic: 'border-orange-500/35 dark:border-orange-500/30',
openai: 'border-green-500/35 dark:border-green-500/30',
antigravity: 'border-purple-500/35 dark:border-purple-500/30',
gemini: 'border-blue-500/35 dark:border-blue-500/30',
grok: 'border-zinc-800/35 dark:border-zinc-500/35',
composite: 'border-cyan-500/35 dark:border-cyan-500/30',
}
const BORDER_STRONG_DEFAULT = 'border-gray-300 dark:border-dark-600'
// ── Accent (single raw color per platform; consumers derive washes/tints
// from it via CSS color-mix, e.g. plaza paid-price zone) ──
const ACCENT: Record<Platform, string> = {
anthropic: '#f97316', // orange-500
openai: '#22c55e', // green-500
antigravity: '#a855f7', // purple-500
gemini: '#3b82f6', // blue-500
grok: '#71717a', // zinc-500
composite: '#06b6d4', // cyan-500
}
const ACCENT_DEFAULT = '#14b8a6' // primary-500 (teal)
// ── Accent bar (gradient) ───────────────────────────────────────────
const ACCENT_BAR: Record<Platform, string> = {
anthropic: 'bg-gradient-to-r from-orange-400 to-orange-500',
@@ -144,6 +167,14 @@ export function platformBorderClass(p: string): string {
return isPlatform(p) ? BORDER[p] : BORDER_DEFAULT
}
export function platformBorderStrongClass(p: string): string {
return isPlatform(p) ? BORDER_STRONG[p] : BORDER_STRONG_DEFAULT
}
export function platformAccentColor(p: string): string {
return isPlatform(p) ? ACCENT[p] : ACCENT_DEFAULT
}
export function platformAccentBarClass(p: string): string {
return isPlatform(p) ? ACCENT_BAR[p] : ACCENT_BAR_DEFAULT
}
+16 -5
View File
@@ -1,13 +1,24 @@
/**
* formatScaled formats a per-token (or per-request) USD price scaled by `scale`.
*
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
* formatScaled(0.000003, 1_000_000) → "$3" // per 1M tokens
* formatScaled(0.5, 1) → "$0.5" // per request
* formatScaled(null, 1_000_000) → "-"
* formatScaled(0.000003, 1_000_000, 2) → "$3.00" // pad to ≥2 decimals
* formatScaled(1.25e-8, 1_000_000, 2) → "$0.0125" // longer decimals kept as-is
*
* Uses toPrecision(10) then strips trailing zeros to avoid IEEE 754 display noise.
* `minFractionDigits` pads the result back up to a minimum number of decimals.
*/
export function formatScaled(value: number | null, scale: number): string {
export function formatScaled(value: number | null, scale: number, minFractionDigits = 0): string {
if (value == null) return '-'
return `$${(value * scale).toPrecision(10).replace(/\.?0+$/, '')}`
let s = (value * scale).toPrecision(10).replace(/\.?0+$/, '')
if (minFractionDigits > 0 && !s.includes('e')) {
const dot = s.indexOf('.')
const digits = dot === -1 ? 0 : s.length - dot - 1
if (digits < minFractionDigits) {
s = (dot === -1 ? `${s}.` : s) + '0'.repeat(minFractionDigits - digits)
}
}
return `$${s}`
}
+48
View File
@@ -0,0 +1,48 @@
<template>
<!-- 后台内嵌形态:?embedded=1 且已登录,套完整后台布局 -->
<AppLayout v-if="isEmbedded">
<ModelPlazaContent :response="data" :loading="loading" :error="loadFailed" embedded />
</AppLayout>
<!-- 独立形态:自带导航条(logo/站名 + 登录/回后台) -->
<div v-else class="min-h-screen bg-gray-50 dark:bg-dark-950">
<PlazaNavBar />
<main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
<ModelPlazaContent :response="data" :loading="loading" :error="loadFailed" />
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import PlazaNavBar from '@/components/modelPlaza/PlazaNavBar.vue'
import ModelPlazaContent from '@/components/modelPlaza/ModelPlazaContent.vue'
import { getModelPlaza, type ModelPlazaResponse } from '@/api/modelPlaza'
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const appStore = useAppStore()
const authStore = useAuthStore()
// embedded=1 但未登录(如转发的链接)自动降级为独立形态。
const isEmbedded = computed(() => route.query.embedded === '1' && authStore.isAuthenticated)
const data = ref<ModelPlazaResponse | null>(null)
const loading = ref(true)
const loadFailed = ref(false)
onMounted(async () => {
// 独立形态导航条需要站点名/Logo;有 __APP_CONFIG__ 注入时同步命中缓存。
void appStore.fetchPublicSettings()
try {
data.value = await getModelPlaza()
} catch {
loadFailed.value = true
} finally {
loading.value = false
}
})
</script>
+58
View File
@@ -6472,6 +6472,56 @@
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.features.modelPlaza.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.enabled') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.enabledHint') }}
</p>
</div>
<Toggle v-model="form.model_plaza_enabled" />
</div>
<div v-if="form.model_plaza_enabled" class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.requireAuth') }}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.requireAuthHint') }}
</p>
</div>
<Toggle v-model="form.model_plaza_require_auth" />
</div>
<div v-if="form.model_plaza_enabled">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.features.modelPlaza.priceDescription') }}
</label>
<p class="mb-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.modelPlaza.priceDescriptionHint') }}
</p>
<textarea
v-model="form.model_plaza_description"
rows="6"
class="input font-mono text-sm"
></textarea>
</div>
</div>
</div>
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
@@ -8990,6 +9040,10 @@ const form = reactive<SettingsForm>({
channel_monitor_default_interval_seconds: 60,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
model_plaza_enabled: false,
model_plaza_require_auth: false,
model_plaza_description: '',
// Affiliate (邀请返利) feature switch
affiliate_enabled: false,
// Allow user view error requests
@@ -10535,6 +10589,10 @@ async function saveSettings() {
Number(form.channel_monitor_default_interval_seconds) || 60,
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
model_plaza_enabled: form.model_plaza_enabled,
model_plaza_require_auth: form.model_plaza_require_auth,
model_plaza_description: form.model_plaza_description,
// Affiliate (邀请返利) feature switch
affiliate_enabled: form.affiliate_enabled,
allow_user_view_error_requests: form.allow_user_view_error_requests,