From b025fdb42dfe528df390e591bc5a78b99ced617e Mon Sep 17 00:00:00 2001 From: shelby36675491 Date: Thu, 9 Jul 2026 12:44:06 +0000 Subject: [PATCH 1/5] feat(backend): add model plaza API (public + authed) aggregating pricing and group rates Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/handler/model_plaza_handler.go | 198 ++++++++++++++++++ .../handler/model_plaza_handler_test.go | 110 ++++++++++ backend/internal/server/routes/user.go | 6 + 3 files changed, 314 insertions(+) create mode 100644 backend/internal/handler/model_plaza_handler.go create mode 100644 backend/internal/handler/model_plaza_handler_test.go diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go new file mode 100644 index 000000000000..855b7a3830da --- /dev/null +++ b/backend/internal/handler/model_plaza_handler.go @@ -0,0 +1,198 @@ +package handler + +import ( + "sort" + "strings" + "sync" + "time" + + "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" +) + +// 模型广场:以「模型」为中心聚合所有活跃渠道的定价与分组倍率, +// 供落地页(匿名版)与用户端模型广场页(登录版)展示。 +// +// 数据完全复用 ChannelService.ListAvailable 的展示链路(含 LiteLLM 全局价格回落), +// 不触碰真实计费逻辑。可见性与「可用渠道」页保持一致: +// - 匿名版仅暴露非专属(!IsExclusive)分组覆盖的模型,且受 +// available-channels 功能开关控制(关闭时返回空列表); +// - 登录版按用户可访问分组过滤,用户专属倍率由前端走 /groups/rates 合并。 + +// modelPlazaEntry 模型广场单个模型条目。 +type modelPlazaEntry struct { + Name string `json:"name"` + Platform string `json:"platform"` + Pricing *userSupportedModelPricing `json:"pricing"` + Groups []userAvailableGroup `json:"groups"` +} + +// plazaPublicCache 匿名版结果的进程内 TTL 缓存,避免公开端点每次请求都全量扫描 +// 渠道与分组表。登录版按用户过滤,不走缓存。 +var plazaPublicCache struct { + mu sync.Mutex + data []modelPlazaEntry + expiresAt time.Time +} + +const plazaPublicCacheTTL = 60 * time.Second + +// ListModelPlazaPublic 匿名版模型广场。 +// GET /api/v1/public/model-plaza +func (h *AvailableChannelHandler) ListModelPlazaPublic(c *gin.Context) { + if !h.featureEnabled(c) { + response.Success(c, []modelPlazaEntry{}) + return + } + + plazaPublicCache.mu.Lock() + if plazaPublicCache.data != nil && time.Now().Before(plazaPublicCache.expiresAt) { + data := plazaPublicCache.data + plazaPublicCache.mu.Unlock() + response.Success(c, data) + return + } + plazaPublicCache.mu.Unlock() + + channels, err := h.channelService.ListAvailable(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + entries := buildModelPlaza(channels, nil) + + plazaPublicCache.mu.Lock() + plazaPublicCache.data = entries + plazaPublicCache.expiresAt = time.Now().Add(plazaPublicCacheTTL) + plazaPublicCache.mu.Unlock() + + response.Success(c, entries) +} + +// ListModelPlaza 登录版模型广场:按当前用户可访问分组过滤。 +// GET /api/v1/model-plaza +func (h *AvailableChannelHandler) ListModelPlaza(c *gin.Context) { + subject, ok := middleware.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + if !h.featureEnabled(c) { + response.Success(c, []modelPlazaEntry{}) + return + } + + userGroups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + allowedGroupIDs := make(map[int64]struct{}, len(userGroups)) + for i := range userGroups { + allowedGroupIDs[userGroups[i].ID] = struct{}{} + } + + channels, err := h.channelService.ListAvailable(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildModelPlaza(channels, allowedGroupIDs)) +} + +// buildModelPlaza 把渠道视图聚合为模型为中心的条目列表。 +// +// allowedGroupIDs 为 nil 表示匿名视角:仅保留非专属分组;否则仅保留用户可访问分组。 +// 同名同平台模型跨渠道合并:定价取首个非空(各渠道未配置时均已回落到全局 +// LiteLLM 数据,展示上等价),分组按 ID 去重后并集。 +func buildModelPlaza( + channels []service.AvailableChannel, + allowedGroupIDs map[int64]struct{}, +) []modelPlazaEntry { + type plazaKey struct{ name, platform string } + byKey := make(map[plazaKey]*modelPlazaEntry) + seenGroups := make(map[plazaKey]map[int64]struct{}) + + for i := range channels { + ch := &channels[i] + if ch.Status != service.StatusActive { + continue + } + + groupsByPlatform := make(map[string][]userAvailableGroup, 4) + for _, g := range ch.Groups { + if g.Platform == "" { + continue + } + if allowedGroupIDs == nil { + if g.IsExclusive { + continue + } + } else if _, ok := allowedGroupIDs[g.ID]; !ok { + continue + } + groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], userAvailableGroup{ + ID: g.ID, + Name: g.Name, + Platform: g.Platform, + SubscriptionType: g.SubscriptionType, + RateMultiplier: g.RateMultiplier, + PeakRateEnabled: g.PeakRateEnabled, + PeakStart: g.PeakStart, + PeakEnd: g.PeakEnd, + PeakRateMultiplier: g.PeakRateMultiplier, + IsExclusive: g.IsExclusive, + }) + } + if len(groupsByPlatform) == 0 { + continue + } + + for j := range ch.SupportedModels { + m := &ch.SupportedModels[j] + platformGroups := groupsByPlatform[m.Platform] + if len(platformGroups) == 0 { + continue + } + key := plazaKey{name: m.Name, platform: m.Platform} + entry, ok := byKey[key] + if !ok { + entry = &modelPlazaEntry{ + Name: m.Name, + Platform: m.Platform, + Pricing: toUserPricing(m.Pricing), + } + byKey[key] = entry + seenGroups[key] = make(map[int64]struct{}) + } + if entry.Pricing == nil && m.Pricing != nil { + entry.Pricing = toUserPricing(m.Pricing) + } + for _, g := range platformGroups { + if _, dup := seenGroups[key][g.ID]; dup { + continue + } + seenGroups[key][g.ID] = struct{}{} + entry.Groups = append(entry.Groups, g) + } + } + } + + out := make([]modelPlazaEntry, 0, len(byKey)) + for _, entry := range byKey { + sort.SliceStable(entry.Groups, func(i, j int) bool { + return entry.Groups[i].Name < entry.Groups[j].Name + }) + out = append(out, *entry) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Platform != out[j].Platform { + return out[i].Platform < out[j].Platform + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + return out +} diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go new file mode 100644 index 000000000000..e553b63e2787 --- /dev/null +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -0,0 +1,110 @@ +//go:build unit + +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func plazaTestChannels() []service.AvailableChannel { + in := 1e-6 + out := 5e-6 + return []service.AvailableChannel{ + { + ID: 1, + Name: "channel-a", + Status: service.StatusActive, + Groups: []service.AvailableGroupRef{ + {ID: 10, Name: "public-claude", Platform: "anthropic", RateMultiplier: 1.0, IsExclusive: false}, + {ID: 11, Name: "vip-claude", Platform: "anthropic", RateMultiplier: 0.8, IsExclusive: true}, + }, + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-5", Platform: "anthropic", Pricing: &service.ChannelModelPricing{InputPrice: &in, OutputPrice: &out}}, + }, + }, + { + ID: 2, + Name: "channel-b", + Status: service.StatusActive, + Groups: []service.AvailableGroupRef{ + {ID: 10, Name: "public-claude", Platform: "anthropic", RateMultiplier: 1.0, IsExclusive: false}, + {ID: 20, Name: "public-openai", Platform: "openai", RateMultiplier: 1.5, IsExclusive: false}, + }, + SupportedModels: []service.SupportedModel{ + {Name: "claude-sonnet-4-5", Platform: "anthropic", Pricing: &service.ChannelModelPricing{InputPrice: &in, OutputPrice: &out}}, + {Name: "gpt-5", Platform: "openai", Pricing: &service.ChannelModelPricing{InputPrice: &in, OutputPrice: &out}}, + }, + }, + { + ID: 3, + Name: "channel-disabled", + Status: "disabled", + Groups: []service.AvailableGroupRef{ + {ID: 30, Name: "public-gemini", Platform: "gemini", RateMultiplier: 1.0, IsExclusive: false}, + }, + SupportedModels: []service.SupportedModel{ + {Name: "gemini-2.5-pro", Platform: "gemini"}, + }, + }, + } +} + +func TestBuildModelPlaza_AnonymousHidesExclusiveGroups(t *testing.T) { + entries := buildModelPlaza(plazaTestChannels(), nil) + + // 停用渠道的 gemini 模型不出现 + require.Len(t, entries, 2) + // 排序:platform 字母序 anthropic < openai + require.Equal(t, "claude-sonnet-4-5", entries[0].Name) + require.Equal(t, "gpt-5", entries[1].Name) + + // claude 模型:跨渠道合并,分组按 ID 去重,专属分组(11)被隐藏 + claude := entries[0] + require.Len(t, claude.Groups, 1) + require.Equal(t, int64(10), claude.Groups[0].ID) + require.NotNil(t, claude.Pricing) + require.NotNil(t, claude.Pricing.InputPrice) +} + +func TestBuildModelPlaza_UserFilterByAllowedGroups(t *testing.T) { + allowed := map[int64]struct{}{11: {}} // 只可访问专属分组 + entries := buildModelPlaza(plazaTestChannels(), allowed) + + require.Len(t, entries, 1) + require.Equal(t, "claude-sonnet-4-5", entries[0].Name) + require.Len(t, entries[0].Groups, 1) + require.Equal(t, int64(11), entries[0].Groups[0].ID) + require.True(t, entries[0].Groups[0].IsExclusive) +} + +func TestModelPlaza_Unauthenticated401(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &AvailableChannelHandler{} // 401 路径不会触达 service 依赖 + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil) + + h.ListModelPlaza(c) + require.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestModelPlazaPublic_FeatureDisabledReturnsEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &AvailableChannelHandler{} // settingService 为 nil → featureEnabled=false + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/public/model-plaza", nil) + + h.ListModelPlazaPublic(c) + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "[]") +} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 9f89687ad115..47988a982a89 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -15,6 +15,9 @@ func RegisterUserRoutes( jwtAuth middleware.JWTAuthMiddleware, settingService *service.SettingService, ) { + // 模型广场(匿名版,供落地页展示;受 available-channels 开关控制) + v1.GET("/public/model-plaza", h.AvailableChannel.ListModelPlazaPublic) + authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) @@ -78,6 +81,9 @@ func RegisterUserRoutes( channels.GET("/available", h.AvailableChannel.List) } + // 模型广场(登录版,按用户可访问分组过滤) + authenticated.GET("/model-plaza", h.AvailableChannel.ListModelPlaza) + // 使用记录 usage := authenticated.Group("/usage") { From 7d5cb75ab7bea3e355279803d71015753a8cad4f Mon Sep 17 00:00:00 2001 From: shelby36675491 Date: Thu, 9 Jul 2026 12:44:06 +0000 Subject: [PATCH 2/5] feat(frontend): add model plaza page and landing pricing preview Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/src/api/modelPlaza.ts | 30 ++ .../landing/ModelPricingPreview.vue | 121 +++++++ frontend/src/components/layout/AppSidebar.vue | 1 + frontend/src/i18n/locales/en/common.ts | 1 + frontend/src/i18n/locales/en/index.ts | 2 + frontend/src/i18n/locales/en/landing.ts | 9 + frontend/src/i18n/locales/en/modelPlaza.ts | 25 ++ frontend/src/i18n/locales/zh/common.ts | 1 + frontend/src/i18n/locales/zh/index.ts | 2 + frontend/src/i18n/locales/zh/landing.ts | 9 + frontend/src/i18n/locales/zh/modelPlaza.ts | 25 ++ frontend/src/router/index.ts | 11 + frontend/src/views/HomeView.vue | 4 + frontend/src/views/user/ModelPlazaView.vue | 334 ++++++++++++++++++ 14 files changed, 575 insertions(+) create mode 100644 frontend/src/api/modelPlaza.ts create mode 100644 frontend/src/components/landing/ModelPricingPreview.vue create mode 100644 frontend/src/i18n/locales/en/modelPlaza.ts create mode 100644 frontend/src/i18n/locales/zh/modelPlaza.ts create mode 100644 frontend/src/views/user/ModelPlazaView.vue diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts new file mode 100644 index 000000000000..27068bef09a5 --- /dev/null +++ b/frontend/src/api/modelPlaza.ts @@ -0,0 +1,30 @@ +/** + * 模型广场 API:以模型为中心聚合定价与分组倍率。 + * 匿名版供落地页与未登录用户浏览;登录版按用户可访问分组过滤。 + */ + +import { apiClient } from './client' +import type { UserAvailableGroup, UserSupportedModelPricing } from './channels' + +export interface ModelPlazaEntry { + name: string + platform: string + pricing: UserSupportedModelPricing | null + groups: UserAvailableGroup[] +} + +/** 匿名版模型广场(仅公开分组)。 */ +export async function getPublicModelPlaza(): Promise { + const { data } = await apiClient.get('/public/model-plaza') + return data || [] +} + +/** 登录版模型广场(按用户可访问分组过滤,含专属分组)。 */ +export async function getModelPlaza(): Promise { + const { data } = await apiClient.get('/model-plaza') + return data || [] +} + +export const modelPlazaAPI = { getPublicModelPlaza, getModelPlaza } + +export default modelPlazaAPI diff --git a/frontend/src/components/landing/ModelPricingPreview.vue b/frontend/src/components/landing/ModelPricingPreview.vue new file mode 100644 index 000000000000..bc39a6f00524 --- /dev/null +++ b/frontend/src/components/landing/ModelPricingPreview.vue @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index ad78c2d298db..30db875c2315 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -704,6 +704,7 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] { { path: '/batch-image', label: t('nav.batchImage'), icon: BatchImageIcon, hideInSimpleMode: true, featureFlag: flagBatchImageAccess }, { path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true }, { path: '/available-channels', label: t('nav.availableChannels'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels }, + { path: '/models', label: t('nav.modelPlaza'), icon: ChannelIcon, hideInSimpleMode: true, featureFlag: flagAvailableChannels }, { path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor }, { path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true }, { path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment }, diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index ac0b8dfcae05..ea6f09299d38 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -163,6 +163,7 @@ export default { groups: 'Groups', channels: 'Channels', availableChannels: 'Available Channels', + modelPlaza: 'Model Plaza', subscriptions: 'Subscriptions', accounts: 'Accounts', proxies: 'Proxies', diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index 377c67aec7c4..c408db418359 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -3,6 +3,7 @@ import common from './common' import dashboard from './dashboard' import admin from './admin' import misc from './misc' +import modelPlaza from './modelPlaza' export default { ...landing, @@ -10,4 +11,5 @@ export default { ...dashboard, admin, ...misc, + ...modelPlaza, } diff --git a/frontend/src/i18n/locales/en/landing.ts b/frontend/src/i18n/locales/en/landing.ts index afbecf4861cd..e8a150cddb86 100644 --- a/frontend/src/i18n/locales/en/landing.ts +++ b/frontend/src/i18n/locales/en/landing.ts @@ -93,6 +93,15 @@ export default { } } }, + pricingPreview: { + title: 'Model Pricing at a Glance', + description: 'Transparent model prices and group rate multipliers — pay for what you use', + model: 'Model', + platform: 'Platform', + input: 'Input / MTok', + output: 'Output / MTok', + viewAll: 'View all models & pricing' + }, providers: { title: 'Supported AI Models', description: 'One API, Multiple Choices', diff --git a/frontend/src/i18n/locales/en/modelPlaza.ts b/frontend/src/i18n/locales/en/modelPlaza.ts new file mode 100644 index 000000000000..d5b44c64ff70 --- /dev/null +++ b/frontend/src/i18n/locales/en/modelPlaza.ts @@ -0,0 +1,25 @@ +export default { + modelPlaza: { + title: 'Model Plaza', + description: 'Browse available models with pricing and group rate multipliers', + searchPlaceholder: 'Search models or groups...', + allPlatforms: 'All', + empty: 'No models available', + noPricing: 'No pricing configured', + input: 'Input', + output: 'Output', + cacheRead: 'Cache Read', + cacheWrite: 'Cache Write', + imageOutput: 'Image Output', + perRequest: 'Per Request', + unitPerMTok: '/ MTok', + unitPerRequest: '/ req', + groups: 'Groups & Multipliers', + exclusiveGroup: 'Exclusive group', + publicGroup: 'Public group', + peakRate: 'Peak {start}-{end} ×{rate}', + customRate: 'Your custom rate', + loginForMore: 'Log in for more', + registerCta: 'Sign up to use these models' + } +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index 44fafef7a4ba..1d0d7388eb26 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -163,6 +163,7 @@ export default { groups: '分组管理', channels: '渠道管理', availableChannels: '可用渠道', + modelPlaza: '模型广场', subscriptions: '订阅管理', accounts: '账号管理', proxies: 'IP管理', diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts index 377c67aec7c4..c408db418359 100644 --- a/frontend/src/i18n/locales/zh/index.ts +++ b/frontend/src/i18n/locales/zh/index.ts @@ -3,6 +3,7 @@ import common from './common' import dashboard from './dashboard' import admin from './admin' import misc from './misc' +import modelPlaza from './modelPlaza' export default { ...landing, @@ -10,4 +11,5 @@ export default { ...dashboard, admin, ...misc, + ...modelPlaza, } diff --git a/frontend/src/i18n/locales/zh/landing.ts b/frontend/src/i18n/locales/zh/landing.ts index 85b7ef48c980..30f754f23abb 100644 --- a/frontend/src/i18n/locales/zh/landing.ts +++ b/frontend/src/i18n/locales/zh/landing.ts @@ -93,6 +93,15 @@ export default { } } }, + pricingPreview: { + title: '模型定价一览', + description: '透明的模型价格与分组倍率,用多少付多少', + model: '模型', + platform: '平台', + input: '输入 / 百万 Token', + output: '输出 / 百万 Token', + viewAll: '查看全部模型与定价' + }, providers: { title: '已支持的 AI 模型', description: '一个 API,多种选择', diff --git a/frontend/src/i18n/locales/zh/modelPlaza.ts b/frontend/src/i18n/locales/zh/modelPlaza.ts new file mode 100644 index 000000000000..fb38632e05c2 --- /dev/null +++ b/frontend/src/i18n/locales/zh/modelPlaza.ts @@ -0,0 +1,25 @@ +export default { + modelPlaza: { + title: '模型广场', + description: '浏览可用模型的定价与分组倍率', + searchPlaceholder: '搜索模型或分组...', + allPlatforms: '全部', + empty: '暂无可用模型', + noPricing: '未配置定价', + input: '输入', + output: '输出', + cacheRead: '缓存读取', + cacheWrite: '缓存写入', + imageOutput: '图片输出', + perRequest: '每次请求', + unitPerMTok: '/ 百万 Token', + unitPerRequest: '/ 次', + groups: '可用分组与倍率', + exclusiveGroup: '专属分组', + publicGroup: '公开分组', + peakRate: '高峰 {start}-{end} ×{rate}', + customRate: '你的专属倍率', + loginForMore: '登录查看更多', + registerCta: '注册即可使用这些模型' + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 306a0eac307f..5155ae5d9d06 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -39,6 +39,17 @@ const routes: RouteRecordRaw[] = [ title: 'Home' } }, + { + path: '/models', + name: 'ModelPlaza', + component: () => import('@/views/user/ModelPlazaView.vue'), + meta: { + requiresAuth: false, + title: 'Model Plaza', + titleKey: 'modelPlaza.title', + descriptionKey: 'modelPlaza.description' + } + }, { path: '/login', name: 'Login', diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index f88742a92ae3..f0f71beab314 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -282,6 +282,9 @@ + + +

@@ -409,6 +412,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' +import ModelPricingPreview from '@/components/landing/ModelPricingPreview.vue' import Icon from '@/components/icons/Icon.vue' import { sanitizeUrl } from '@/utils/url' diff --git a/frontend/src/views/user/ModelPlazaView.vue b/frontend/src/views/user/ModelPlazaView.vue new file mode 100644 index 000000000000..8ce7bb50eb0f --- /dev/null +++ b/frontend/src/views/user/ModelPlazaView.vue @@ -0,0 +1,334 @@ + + + From 97a212fa108af8a06879b56675a2a2646ba044a4 Mon Sep 17 00:00:00 2001 From: shelby36675491 Date: Thu, 9 Jul 2026 13:11:11 +0000 Subject: [PATCH 3/5] refactor: rework model plaza as dashboard-only page with group/provider/type filters and card/list views; drop landing pricing preview and public endpoint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal/handler/model_plaza_handler.go | 64 +- .../handler/model_plaza_handler_test.go | 18 +- backend/internal/server/routes/user.go | 3 - frontend/src/api/modelPlaza.ts | 13 +- .../landing/ModelPricingPreview.vue | 121 ---- frontend/src/i18n/locales/en/landing.ts | 9 - frontend/src/i18n/locales/en/modelPlaza.ts | 30 +- frontend/src/i18n/locales/zh/landing.ts | 9 - frontend/src/i18n/locales/zh/modelPlaza.ts | 34 +- frontend/src/router/index.ts | 3 +- frontend/src/views/HomeView.vue | 4 - frontend/src/views/user/ModelPlazaView.vue | 570 +++++++++++------- 12 files changed, 414 insertions(+), 464 deletions(-) delete mode 100644 frontend/src/components/landing/ModelPricingPreview.vue diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 855b7a3830da..1811d3f195f0 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -3,8 +3,6 @@ package handler import ( "sort" "strings" - "sync" - "time" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -13,14 +11,12 @@ import ( "github.com/gin-gonic/gin" ) -// 模型广场:以「模型」为中心聚合所有活跃渠道的定价与分组倍率, -// 供落地页(匿名版)与用户端模型广场页(登录版)展示。 +// 模型广场:以「模型」为中心聚合所有活跃渠道的定价与分组倍率,供用户端模型广场页展示。 // // 数据完全复用 ChannelService.ListAvailable 的展示链路(含 LiteLLM 全局价格回落), -// 不触碰真实计费逻辑。可见性与「可用渠道」页保持一致: -// - 匿名版仅暴露非专属(!IsExclusive)分组覆盖的模型,且受 -// available-channels 功能开关控制(关闭时返回空列表); -// - 登录版按用户可访问分组过滤,用户专属倍率由前端走 /groups/rates 合并。 +// 不触碰真实计费逻辑。可见性与「可用渠道」页保持一致:按用户可访问分组过滤, +// 受 available-channels 功能开关控制(关闭时返回空列表), +// 用户专属倍率由前端走 /groups/rates 合并。 // modelPlazaEntry 模型广场单个模型条目。 type modelPlazaEntry struct { @@ -30,48 +26,6 @@ type modelPlazaEntry struct { Groups []userAvailableGroup `json:"groups"` } -// plazaPublicCache 匿名版结果的进程内 TTL 缓存,避免公开端点每次请求都全量扫描 -// 渠道与分组表。登录版按用户过滤,不走缓存。 -var plazaPublicCache struct { - mu sync.Mutex - data []modelPlazaEntry - expiresAt time.Time -} - -const plazaPublicCacheTTL = 60 * time.Second - -// ListModelPlazaPublic 匿名版模型广场。 -// GET /api/v1/public/model-plaza -func (h *AvailableChannelHandler) ListModelPlazaPublic(c *gin.Context) { - if !h.featureEnabled(c) { - response.Success(c, []modelPlazaEntry{}) - return - } - - plazaPublicCache.mu.Lock() - if plazaPublicCache.data != nil && time.Now().Before(plazaPublicCache.expiresAt) { - data := plazaPublicCache.data - plazaPublicCache.mu.Unlock() - response.Success(c, data) - return - } - plazaPublicCache.mu.Unlock() - - channels, err := h.channelService.ListAvailable(c.Request.Context()) - if err != nil { - response.ErrorFrom(c, err) - return - } - entries := buildModelPlaza(channels, nil) - - plazaPublicCache.mu.Lock() - plazaPublicCache.data = entries - plazaPublicCache.expiresAt = time.Now().Add(plazaPublicCacheTTL) - plazaPublicCache.mu.Unlock() - - response.Success(c, entries) -} - // ListModelPlaza 登录版模型广场:按当前用户可访问分组过滤。 // GET /api/v1/model-plaza func (h *AvailableChannelHandler) ListModelPlaza(c *gin.Context) { @@ -103,9 +57,7 @@ func (h *AvailableChannelHandler) ListModelPlaza(c *gin.Context) { response.Success(c, buildModelPlaza(channels, allowedGroupIDs)) } -// buildModelPlaza 把渠道视图聚合为模型为中心的条目列表。 -// -// allowedGroupIDs 为 nil 表示匿名视角:仅保留非专属分组;否则仅保留用户可访问分组。 +// buildModelPlaza 把渠道视图聚合为模型为中心的条目列表,仅保留 allowedGroupIDs 中的分组。 // 同名同平台模型跨渠道合并:定价取首个非空(各渠道未配置时均已回落到全局 // LiteLLM 数据,展示上等价),分组按 ID 去重后并集。 func buildModelPlaza( @@ -127,11 +79,7 @@ func buildModelPlaza( if g.Platform == "" { continue } - if allowedGroupIDs == nil { - if g.IsExclusive { - continue - } - } else if _, ok := allowedGroupIDs[g.ID]; !ok { + if _, ok := allowedGroupIDs[g.ID]; !ok { continue } groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], userAvailableGroup{ diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index e553b63e2787..3b79428db1c9 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -56,8 +56,9 @@ func plazaTestChannels() []service.AvailableChannel { } } -func TestBuildModelPlaza_AnonymousHidesExclusiveGroups(t *testing.T) { - entries := buildModelPlaza(plazaTestChannels(), nil) +func TestBuildModelPlaza_MergesChannelsAndDedupesGroups(t *testing.T) { + allowed := map[int64]struct{}{10: {}, 20: {}} + entries := buildModelPlaza(plazaTestChannels(), allowed) // 停用渠道的 gemini 模型不出现 require.Len(t, entries, 2) @@ -65,7 +66,7 @@ func TestBuildModelPlaza_AnonymousHidesExclusiveGroups(t *testing.T) { require.Equal(t, "claude-sonnet-4-5", entries[0].Name) require.Equal(t, "gpt-5", entries[1].Name) - // claude 模型:跨渠道合并,分组按 ID 去重,专属分组(11)被隐藏 + // claude 模型:跨渠道合并,分组按 ID 去重,不可访问的分组(11)被过滤 claude := entries[0] require.Len(t, claude.Groups, 1) require.Equal(t, int64(10), claude.Groups[0].ID) @@ -96,15 +97,4 @@ func TestModelPlaza_Unauthenticated401(t *testing.T) { require.Equal(t, http.StatusUnauthorized, w.Code) } -func TestModelPlazaPublic_FeatureDisabledReturnsEmpty(t *testing.T) { - gin.SetMode(gin.TestMode) - h := &AvailableChannelHandler{} // settingService 为 nil → featureEnabled=false - - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/public/model-plaza", nil) - h.ListModelPlazaPublic(c) - require.Equal(t, http.StatusOK, w.Code) - require.Contains(t, w.Body.String(), "[]") -} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 47988a982a89..4571c2d88b15 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -15,9 +15,6 @@ func RegisterUserRoutes( jwtAuth middleware.JWTAuthMiddleware, settingService *service.SettingService, ) { - // 模型广场(匿名版,供落地页展示;受 available-channels 开关控制) - v1.GET("/public/model-plaza", h.AvailableChannel.ListModelPlazaPublic) - authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index 27068bef09a5..9c146cdf2ae1 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -1,6 +1,5 @@ /** - * 模型广场 API:以模型为中心聚合定价与分组倍率。 - * 匿名版供落地页与未登录用户浏览;登录版按用户可访问分组过滤。 + * 模型广场 API:以模型为中心聚合定价与分组倍率,按用户可访问分组过滤。 */ import { apiClient } from './client' @@ -13,18 +12,12 @@ export interface ModelPlazaEntry { groups: UserAvailableGroup[] } -/** 匿名版模型广场(仅公开分组)。 */ -export async function getPublicModelPlaza(): Promise { - const { data } = await apiClient.get('/public/model-plaza') - return data || [] -} - -/** 登录版模型广场(按用户可访问分组过滤,含专属分组)。 */ +/** 模型广场(按用户可访问分组过滤,含专属分组)。 */ export async function getModelPlaza(): Promise { const { data } = await apiClient.get('/model-plaza') return data || [] } -export const modelPlazaAPI = { getPublicModelPlaza, getModelPlaza } +export const modelPlazaAPI = { getModelPlaza } export default modelPlazaAPI diff --git a/frontend/src/components/landing/ModelPricingPreview.vue b/frontend/src/components/landing/ModelPricingPreview.vue deleted file mode 100644 index bc39a6f00524..000000000000 --- a/frontend/src/components/landing/ModelPricingPreview.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/frontend/src/i18n/locales/en/landing.ts b/frontend/src/i18n/locales/en/landing.ts index e8a150cddb86..afbecf4861cd 100644 --- a/frontend/src/i18n/locales/en/landing.ts +++ b/frontend/src/i18n/locales/en/landing.ts @@ -93,15 +93,6 @@ export default { } } }, - pricingPreview: { - title: 'Model Pricing at a Glance', - description: 'Transparent model prices and group rate multipliers — pay for what you use', - model: 'Model', - platform: 'Platform', - input: 'Input / MTok', - output: 'Output / MTok', - viewAll: 'View all models & pricing' - }, providers: { title: 'Supported AI Models', description: 'One API, Multiple Choices', diff --git a/frontend/src/i18n/locales/en/modelPlaza.ts b/frontend/src/i18n/locales/en/modelPlaza.ts index d5b44c64ff70..4c5aad0ddc98 100644 --- a/frontend/src/i18n/locales/en/modelPlaza.ts +++ b/frontend/src/i18n/locales/en/modelPlaza.ts @@ -1,9 +1,24 @@ export default { modelPlaza: { title: 'Model Plaza', - description: 'Browse available models with pricing and group rate multipliers', - searchPlaceholder: 'Search models or groups...', - allPlatforms: 'All', + description: 'Browse models available to your groups with official pricing', + bannerDescription: 'Explore model pricing and group information', + searchPlaceholder: 'Search model name or provider...', + allGroups: 'All Groups', + provider: 'Provider', + allProviders: 'All Providers', + type: 'Type', + allTypes: 'All Types', + typeChat: 'Chat', + typePerRequest: 'Per Request', + sortName: 'Model Name', + sortInputAsc: 'Input Price ↑', + sortInputDesc: 'Input Price ↓', + cardView: 'Card view', + listView: 'List view', + modelsCount: '{count} models available', + available: 'Available', + model: 'Model', empty: 'No models available', noPricing: 'No pricing configured', input: 'Input', @@ -12,14 +27,13 @@ export default { cacheWrite: 'Cache Write', imageOutput: 'Image Output', perRequest: 'Per Request', - unitPerMTok: '/ MTok', - unitPerRequest: '/ req', + unitPerMTok: '$/M tokens', + unitPerRequest: '$/request', groups: 'Groups & Multipliers', + collapse: 'Collapse', exclusiveGroup: 'Exclusive group', publicGroup: 'Public group', peakRate: 'Peak {start}-{end} ×{rate}', - customRate: 'Your custom rate', - loginForMore: 'Log in for more', - registerCta: 'Sign up to use these models' + customRate: 'Your custom rate' } } diff --git a/frontend/src/i18n/locales/zh/landing.ts b/frontend/src/i18n/locales/zh/landing.ts index 30f754f23abb..85b7ef48c980 100644 --- a/frontend/src/i18n/locales/zh/landing.ts +++ b/frontend/src/i18n/locales/zh/landing.ts @@ -93,15 +93,6 @@ export default { } } }, - pricingPreview: { - title: '模型定价一览', - description: '透明的模型价格与分组倍率,用多少付多少', - model: '模型', - platform: '平台', - input: '输入 / 百万 Token', - output: '输出 / 百万 Token', - viewAll: '查看全部模型与定价' - }, providers: { title: '已支持的 AI 模型', description: '一个 API,多种选择', diff --git a/frontend/src/i18n/locales/zh/modelPlaza.ts b/frontend/src/i18n/locales/zh/modelPlaza.ts index fb38632e05c2..6fa621787b78 100644 --- a/frontend/src/i18n/locales/zh/modelPlaza.ts +++ b/frontend/src/i18n/locales/zh/modelPlaza.ts @@ -1,25 +1,39 @@ export default { modelPlaza: { title: '模型广场', - description: '浏览可用模型的定价与分组倍率', - searchPlaceholder: '搜索模型或分组...', - allPlatforms: '全部', + description: '查看您可用分组的模型及官方定价', + bannerDescription: '探索可用模型的定价与分组信息', + searchPlaceholder: '搜索模型名称或提供商...', + allGroups: '全部分组', + provider: '提供商', + allProviders: '全部提供商', + type: '类型', + allTypes: '全部类型', + typeChat: '对话', + typePerRequest: '按次', + sortName: '模型名称', + sortInputAsc: '输入价格 ↑', + sortInputDesc: '输入价格 ↓', + cardView: '卡片视图', + listView: '列表视图', + modelsCount: '{count} 个可用模型', + available: '可用', + model: '模型', empty: '暂无可用模型', noPricing: '未配置定价', input: '输入', output: '输出', - cacheRead: '缓存读取', - cacheWrite: '缓存写入', + cacheRead: 'Cache Read', + cacheWrite: 'Cache Write', imageOutput: '图片输出', perRequest: '每次请求', - unitPerMTok: '/ 百万 Token', - unitPerRequest: '/ 次', + unitPerMTok: '$/M tokens', + unitPerRequest: '$/次', groups: '可用分组与倍率', + collapse: '收起', exclusiveGroup: '专属分组', publicGroup: '公开分组', peakRate: '高峰 {start}-{end} ×{rate}', - customRate: '你的专属倍率', - loginForMore: '登录查看更多', - registerCta: '注册即可使用这些模型' + customRate: '你的专属倍率' } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5155ae5d9d06..603c4f1e9490 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -44,7 +44,8 @@ const routes: RouteRecordRaw[] = [ name: 'ModelPlaza', component: () => import('@/views/user/ModelPlazaView.vue'), meta: { - requiresAuth: false, + requiresAuth: true, + requiresAdmin: false, title: 'Model Plaza', titleKey: 'modelPlaza.title', descriptionKey: 'modelPlaza.description' diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index f0f71beab314..f88742a92ae3 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -282,9 +282,6 @@

- - -

@@ -412,7 +409,6 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useAuthStore, useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' -import ModelPricingPreview from '@/components/landing/ModelPricingPreview.vue' import Icon from '@/components/icons/Icon.vue' import { sanitizeUrl } from '@/utils/url' diff --git a/frontend/src/views/user/ModelPlazaView.vue b/frontend/src/views/user/ModelPlazaView.vue index 8ce7bb50eb0f..950ca71b1ef0 100644 --- a/frontend/src/views/user/ModelPlazaView.vue +++ b/frontend/src/views/user/ModelPlazaView.vue @@ -1,170 +1,247 @@ From 5b687fd53f20e992f172ee7275f8d27e64a850dd Mon Sep 17 00:00:00 2001 From: shelby36675491 Date: Thu, 9 Jul 2026 14:00:24 +0000 Subject: [PATCH 4/5] style(frontend): apply Apple-style design to model plaza (aurora hero, frosted toolbar, iOS segmented control, refined cards and motion) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- frontend/src/views/user/ModelPlazaView.vue | 336 +++++++++++++++------ 1 file changed, 251 insertions(+), 85 deletions(-) diff --git a/frontend/src/views/user/ModelPlazaView.vue b/frontend/src/views/user/ModelPlazaView.vue index 950ca71b1ef0..24bfb5e42e38 100644 --- a/frontend/src/views/user/ModelPlazaView.vue +++ b/frontend/src/views/user/ModelPlazaView.vue @@ -1,88 +1,88 @@