mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 15:08:02 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -166,6 +166,7 @@ export default {
|
||||
groups: 'Groups',
|
||||
channels: 'Channels',
|
||||
availableChannels: 'Available Channels',
|
||||
modelPlaza: 'Model Plaza',
|
||||
subscriptions: 'Subscriptions',
|
||||
accounts: 'Accounts',
|
||||
proxies: 'Proxies',
|
||||
|
||||
@@ -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: '启用内容审计菜单和全端点请求审核入口。默认关闭。',
|
||||
|
||||
@@ -166,6 +166,7 @@ export default {
|
||||
groups: '分组管理',
|
||||
channels: '渠道管理',
|
||||
availableChannels: '可用渠道',
|
||||
modelPlaza: '模型广场',
|
||||
subscriptions: '订阅管理',
|
||||
accounts: '账号管理',
|
||||
proxies: 'IP管理',
|
||||
|
||||
@@ -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: '邀请新用户注册,并将返利额度转入账户余额',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user