mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
feat(settings): 新增「站点类型」三态开关,关闭订阅后用户端收起所有「订阅」入口与文案
后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。 它由两个后端开关派生并在保存时同时写回: - subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有 开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表; - payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库 改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。 两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。 「仅充值」时的表现: - 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」 入口(页面仍可通过地址访问); - 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab 自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示 「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单; - /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换; - /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘; - 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口; - 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。 「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。 购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。 featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解, PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
This commit is contained in:
@@ -383,6 +383,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: settings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -579,6 +579,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||
changed = append(changed, "available_channels_enabled")
|
||||
}
|
||||
if before.SubscriptionEnabled != after.SubscriptionEnabled {
|
||||
changed = append(changed, "subscription_enabled")
|
||||
}
|
||||
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
|
||||
changed = append(changed, "model_plaza_enabled")
|
||||
}
|
||||
|
||||
@@ -186,3 +186,20 @@ func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *t
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "positive integer")
|
||||
}
|
||||
|
||||
// subscription_enabled is an opt-out switch: an explicit false is written as-is,
|
||||
// and a later payload that omits the field keeps the stored value.
|
||||
func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) {
|
||||
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
|
||||
service.SettingKeySubscriptionEnabled: "true",
|
||||
})
|
||||
|
||||
rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled])
|
||||
|
||||
rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled],
|
||||
"a payload without subscription_enabled must not flip the stored value back to true")
|
||||
}
|
||||
|
||||
@@ -344,6 +344,9 @@ type UpdateSettingsRequest struct {
|
||||
// Available Channels feature switch (user-facing)
|
||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch (user-facing subscription surface; see SettingKeySubscriptionEnabled)
|
||||
SubscriptionEnabled *bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature switches + description
|
||||
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
@@ -1937,6 +1940,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.AvailableChannelsEnabled
|
||||
}(),
|
||||
SubscriptionEnabled: func() bool {
|
||||
if req.SubscriptionEnabled != nil {
|
||||
return *req.SubscriptionEnabled
|
||||
}
|
||||
return previousSettings.SubscriptionEnabled
|
||||
}(),
|
||||
ModelPlazaEnabled: func() bool {
|
||||
if req.ModelPlazaEnabled != nil {
|
||||
return *req.ModelPlazaEnabled
|
||||
@@ -2380,6 +2389,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: updatedSettings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -317,6 +317,10 @@ type SystemSettings struct {
|
||||
// Available Channels feature switch (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch: gates the whole user-facing subscription surface
|
||||
// (sidebar entries, purchase-page subscription tab, header badge, /subscriptions route).
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
@@ -403,7 +407,11 @@ type PublicSettings struct {
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
// PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the
|
||||
// user shell can derive the site billing mode (recharge & subscription / recharge only /
|
||||
// subscription only) before any authenticated checkout call.
|
||||
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
|
||||
Version string `json:"version"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
|
||||
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
@@ -422,6 +430,8 @@ type PublicSettings struct {
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
|
||||
@@ -95,6 +95,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
PaymentBalanceDisabled: settings.PaymentBalanceDisabled,
|
||||
Version: h.version,
|
||||
ServerTimezone: timezone.Name(),
|
||||
ServerUTCOffset: timezone.UTCOffset(),
|
||||
@@ -111,6 +112,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: settings.SubscriptionEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -995,6 +995,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_hide_user_ranking": false,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"subscription_enabled": true,
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
@@ -1309,6 +1310,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_hide_user_ranking": false,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"subscription_enabled": true,
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
|
||||
@@ -513,6 +513,15 @@ const (
|
||||
// sidebar entry is hidden. Defaults to false (opt-in feature).
|
||||
SettingKeyAvailableChannelsEnabled = "available_channels_enabled"
|
||||
|
||||
// SettingKeySubscriptionEnabled is a DB-backed soft switch for the user-facing
|
||||
// subscription surface: sidebar entries, purchase-page subscription tab, header
|
||||
// progress badge, usage billing-type filter and the /subscriptions route. When
|
||||
// false users can no longer buy or browse subscriptions from the UI; the
|
||||
// subscriptions API, existing subscription billing and admin subscription
|
||||
// management are unaffected. Together with BALANCE_PAYMENT_DISABLED it forms the
|
||||
// admin "site billing mode" selector. Defaults to true (opt-out feature).
|
||||
SettingKeySubscriptionEnabled = "subscription_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).
|
||||
|
||||
@@ -202,6 +202,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
// Available channels feature (default disabled; opt-in)
|
||||
SettingKeyAvailableChannelsEnabled: "false",
|
||||
|
||||
// Subscription feature (default enabled; opt-out)
|
||||
SettingKeySubscriptionEnabled: "true",
|
||||
|
||||
// Model plaza feature (default disabled; opt-in, public unless require_auth)
|
||||
SettingKeyModelPlazaEnabled: "false",
|
||||
SettingKeyModelPlazaRequireAuth: "false",
|
||||
@@ -820,6 +823,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
// Available channels feature (default: disabled; strict true)
|
||||
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
|
||||
|
||||
// Subscription feature (default: enabled; only an explicit false disables)
|
||||
result.SubscriptionEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionEnabled])
|
||||
|
||||
// Model plaza feature (default: disabled; strict true)
|
||||
result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true"
|
||||
result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true"
|
||||
|
||||
@@ -215,6 +215,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyWeChatConnectFrontendRedirectURL,
|
||||
SettingKeyBackendModeEnabled,
|
||||
SettingPaymentEnabled,
|
||||
SettingBalancePayDisabled,
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
SettingKeyGitHubOAuthEnabled,
|
||||
@@ -234,6 +235,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyChannelMonitorShowQuota,
|
||||
SettingKeyChannelMonitorHideUserRanking,
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeySubscriptionEnabled,
|
||||
SettingKeyModelPlazaEnabled,
|
||||
SettingKeyModelPlazaRequireAuth,
|
||||
SettingKeyPluginManagementEnabled,
|
||||
@@ -345,6 +347,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
WeChatOAuthMobileEnabled: weChatMobileEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
PaymentBalanceDisabled: settings[SettingBalancePayDisabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
OIDCOAuthProviderName: oidcProviderName,
|
||||
GitHubOAuthEnabled: gitHubEnabled,
|
||||
@@ -363,6 +366,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
SubscriptionEnabled: !isFalseSettingValue(settings[SettingKeySubscriptionEnabled]),
|
||||
|
||||
ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true",
|
||||
ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true",
|
||||
PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true",
|
||||
@@ -605,6 +610,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
|
||||
Version string `json:"version"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
@@ -630,6 +636,7 @@ type PublicSettingsInjectionPayload struct {
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
@@ -697,6 +704,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
PaymentBalanceDisabled: settings.PaymentBalanceDisabled,
|
||||
Version: s.version,
|
||||
ServerTimezone: timezone.Name(),
|
||||
ServerUTCOffset: timezone.UTCOffset(),
|
||||
@@ -712,6 +720,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
SubscriptionEnabled: settings.SubscriptionEnabled,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
PluginManagementEnabled: settings.PluginManagementEnabled,
|
||||
|
||||
@@ -240,3 +240,72 @@ func TestSettingService_GetPublicSettings_FallsBackToConfigForWeChatOAuthCapabil
|
||||
require.False(t, settings.WeChatOAuthMPEnabled)
|
||||
require.False(t, settings.WeChatOAuthMobileEnabled)
|
||||
}
|
||||
|
||||
// subscription_enabled gates the user-facing "My Subscriptions" sidebar entry and
|
||||
// is opt-out: only an explicit "false" hides it.
|
||||
func TestSettingService_GetPublicSettings_SubscriptionEnabledOnlyExplicitFalseDisables(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
values map[string]string
|
||||
want bool
|
||||
}{
|
||||
{name: "missing key defaults to enabled", values: map[string]string{}, want: true},
|
||||
{name: "empty value stays enabled", values: map[string]string{SettingKeySubscriptionEnabled: ""}, want: true},
|
||||
{name: "explicit true", values: map[string]string{SettingKeySubscriptionEnabled: "true"}, want: true},
|
||||
{name: "explicit false disables", values: map[string]string{SettingKeySubscriptionEnabled: "false"}, want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := NewSettingService(&settingPublicRepoStub{values: tc.values}, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, settings.SubscriptionEnabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The SSR injection payload must mirror the switch so the opt-out flag does not
|
||||
// flicker off on first render before the async public-settings fetch resolves.
|
||||
func TestSettingService_GetPublicSettingsForInjection_MirrorsSubscriptionEnabled(t *testing.T) {
|
||||
for _, value := range []string{"false", "true"} {
|
||||
repo := &settingPublicRepoStub{values: map[string]string{SettingKeySubscriptionEnabled: value}}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
raw, err := svc.GetPublicSettingsForInjection(context.Background())
|
||||
require.NoError(t, err)
|
||||
payload, ok := raw.(*PublicSettingsInjectionPayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, value == "true", payload.SubscriptionEnabled, "value=%q", value)
|
||||
}
|
||||
}
|
||||
|
||||
// payment_balance_disabled is exposed publicly so the user shell can derive the site
|
||||
// billing mode (recharge & subscription / recharge only / subscription only) before any
|
||||
// authenticated checkout call. Strict true, mirroring the payment-config parser.
|
||||
func TestSettingService_GetPublicSettings_PaymentBalanceDisabledStrictTrue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value map[string]string
|
||||
want bool
|
||||
}{
|
||||
{name: "missing key stays enabled", value: map[string]string{}, want: false},
|
||||
{name: "explicit false", value: map[string]string{SettingBalancePayDisabled: "false"}, want: false},
|
||||
{name: "explicit true disables balance recharge", value: map[string]string{SettingBalancePayDisabled: "true"}, want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := NewSettingService(&settingPublicRepoStub{values: tc.value}, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.want, settings.PaymentBalanceDisabled)
|
||||
|
||||
raw, err := svc.GetPublicSettingsForInjection(context.Background())
|
||||
require.NoError(t, err)
|
||||
payload, ok := raw.(*PublicSettingsInjectionPayload)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, tc.want, payload.PaymentBalanceDisabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
// Available channels feature switch
|
||||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||||
|
||||
// Subscription feature switch
|
||||
updates[SettingKeySubscriptionEnabled] = strconv.FormatBool(settings.SubscriptionEnabled)
|
||||
|
||||
// Model plaza feature switches + description
|
||||
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
|
||||
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
|
||||
|
||||
@@ -212,6 +212,12 @@ type SystemSettings struct {
|
||||
// Available Channels feature (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch: gates the whole user-facing subscription surface
|
||||
// (sidebar entries, purchase-page subscription tab, header progress badge,
|
||||
// usage billing-type filter, /subscriptions route). Pairs with PaymentBalanceDisabled
|
||||
// to form the admin-facing "site billing mode" selector.
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
@@ -366,6 +372,7 @@ type PublicSettings struct {
|
||||
WeChatOAuthMobileEnabled bool
|
||||
BackendModeEnabled bool
|
||||
PaymentEnabled bool
|
||||
PaymentBalanceDisabled bool
|
||||
OIDCOAuthEnabled bool
|
||||
OIDCOAuthProviderName string
|
||||
GitHubOAuthEnabled bool
|
||||
@@ -393,6 +400,9 @@ type PublicSettings struct {
|
||||
// Available Channels feature (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Subscription feature switch (see SystemSettings.SubscriptionEnabled)
|
||||
SubscriptionEnabled bool `json:"subscription_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
|
||||
+32
-7
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Toast from '@/components/common/Toast.vue'
|
||||
import NavigationProgress from '@/components/common/NavigationProgress.vue'
|
||||
import AdminComplianceDialog from '@/components/admin/AdminComplianceDialog.vue'
|
||||
@@ -9,6 +9,8 @@ import AnnouncementPopup from '@/components/common/AnnouncementPopup.vue'
|
||||
import { useAppStore, useAuthStore, useSubscriptionStore, useAnnouncementStore, useAdminComplianceStore, useAdminSettingsStore } from '@/stores'
|
||||
import { getSetupStatus } from '@/api/setup'
|
||||
import { updateFavicon } from '@/utils/branding'
|
||||
import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
|
||||
import { resolveSiteBillingMode } from '@/utils/siteBillingMode'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -24,7 +26,9 @@ function updateDocumentTitle() {
|
||||
...(appStore.cachedPublicSettings?.custom_menu_items ?? []),
|
||||
...(authStore.isAdmin ? adminSettingsStore.customMenuItems : []),
|
||||
]
|
||||
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems)
|
||||
document.title = resolveRouteDocumentTitle(route, appStore.siteName, customMenuItems, {
|
||||
billingMode: resolveSiteBillingMode(appStore.cachedPublicSettings),
|
||||
})
|
||||
}
|
||||
|
||||
// Watch for site settings changes and update favicon/title
|
||||
@@ -45,6 +49,8 @@ watch(
|
||||
() => route.meta.titleKey,
|
||||
() => appStore.siteName,
|
||||
() => appStore.cachedPublicSettings?.custom_menu_items,
|
||||
() => appStore.cachedPublicSettings?.subscription_enabled,
|
||||
() => appStore.cachedPublicSettings?.payment_balance_disabled,
|
||||
() => authStore.isAdmin,
|
||||
() => adminSettingsStore.customMenuItems,
|
||||
],
|
||||
@@ -64,6 +70,25 @@ function onAdminComplianceRequired(event: Event) {
|
||||
adminComplianceStore.requireAcknowledgement(detail)
|
||||
}
|
||||
|
||||
// 订阅功能开关(opt-out)。关闭后不再预加载/轮询订阅接口;开关在登录后才到达时补启动,反向则清空。
|
||||
const subscriptionFeatureEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.subscription))
|
||||
|
||||
function startSubscriptionSync() {
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to preload subscriptions:', error)
|
||||
})
|
||||
subscriptionStore.startPolling()
|
||||
}
|
||||
|
||||
watch(subscriptionFeatureEnabled, (enabled) => {
|
||||
if (!authStore.isAuthenticated) return
|
||||
if (enabled) {
|
||||
startSubscriptionSync()
|
||||
} else {
|
||||
subscriptionStore.clear()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
(isAuthenticated, oldValue) => {
|
||||
@@ -74,11 +99,11 @@ watch(
|
||||
})
|
||||
}
|
||||
|
||||
// User logged in: preload subscriptions and start polling
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to preload subscriptions:', error)
|
||||
})
|
||||
subscriptionStore.startPolling()
|
||||
// User logged in: preload subscriptions and start polling (skipped when the
|
||||
// subscription feature is switched off; see the flag watcher below)
|
||||
if (subscriptionFeatureEnabled.value) {
|
||||
startSubscriptionSync()
|
||||
}
|
||||
|
||||
// Announcements: new login vs page refresh restore
|
||||
if (oldValue === false) {
|
||||
|
||||
@@ -727,6 +727,9 @@ export interface SystemSettings {
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: boolean;
|
||||
|
||||
// Subscription feature switch (user sidebar "My Subscriptions" entry)
|
||||
subscription_enabled: boolean;
|
||||
|
||||
// Model Plaza feature switches + description
|
||||
model_plaza_enabled: boolean;
|
||||
model_plaza_require_auth: boolean;
|
||||
@@ -1029,6 +1032,9 @@ export interface UpdateSettingsRequest {
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled?: boolean;
|
||||
|
||||
// Subscription feature switch
|
||||
subscription_enabled?: boolean;
|
||||
|
||||
// Model Plaza feature switches + description
|
||||
model_plaza_enabled?: boolean;
|
||||
model_plaza_require_auth?: boolean;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="hasActiveSubscriptions" class="relative" ref="containerRef">
|
||||
<div v-if="subscriptionFeatureEnabled && hasActiveSubscriptions" class="relative" ref="containerRef">
|
||||
<!-- Mini Progress Display -->
|
||||
<button
|
||||
@click="toggleTooltip"
|
||||
@@ -182,6 +182,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useSubscriptionStore } from '@/stores'
|
||||
import { FeatureFlags, isFeatureFlagEnabled } from '@/utils/featureFlags'
|
||||
import type { UserSubscription } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -194,6 +195,8 @@ const tooltipOpen = ref(false)
|
||||
// Use store data instead of local state
|
||||
const activeSubscriptions = computed(() => subscriptionStore.activeSubscriptions)
|
||||
const hasActiveSubscriptions = computed(() => subscriptionStore.hasActiveSubscriptions)
|
||||
// 订阅功能关闭后,即使用户仍持有后台分配的订阅,顶栏也不再露出订阅进度与「查看全部订阅」入口。
|
||||
const subscriptionFeatureEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.subscription))
|
||||
|
||||
const displaySubscriptions = computed(() => {
|
||||
// Sort by most usage (highest percentage first)
|
||||
@@ -296,6 +299,7 @@ onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
// Trigger initial fetch if not already loaded
|
||||
// The actual data loading is handled by App.vue globally
|
||||
if (!subscriptionFeatureEnabled.value) return
|
||||
subscriptionStore.fetchActiveSubscriptions().catch((error) => {
|
||||
console.error('Failed to load subscriptions in SubscriptionProgressMini:', error)
|
||||
})
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
<!-- Language Switcher -->
|
||||
<LocaleSwitcher />
|
||||
|
||||
<!-- Subscription Progress (for users with active subscriptions) -->
|
||||
<SubscriptionProgressMini v-if="user" />
|
||||
<!-- Subscription Progress (for users with active subscriptions; not mounted at all when the feature is off) -->
|
||||
<SubscriptionProgressMini v-if="user && subscriptionFeatureEnabled" />
|
||||
|
||||
<!-- Balance Display -->
|
||||
<div
|
||||
@@ -261,6 +261,8 @@ 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'
|
||||
import { resolveRouteMetaKeys } from '@/router/title'
|
||||
import { resolveSiteBillingMode } from '@/utils/siteBillingMode'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -309,6 +311,14 @@ const displayName = computed(() => {
|
||||
return user.value.username || user.value.email?.split('@')[0] || ''
|
||||
})
|
||||
|
||||
// 订阅功能关闭时不挂载顶栏订阅徽章(组件 onMounted 会拉取订阅接口)。
|
||||
const subscriptionFeatureEnabled = computed(() => isFeatureFlagEnabled(FeatureFlags.subscription))
|
||||
|
||||
// /purchase 的标题/描述随站点计费模式切换,与 document.title 共用同一解析。
|
||||
const routeMetaKeys = computed(() => resolveRouteMetaKeys(route, {
|
||||
billingMode: resolveSiteBillingMode(appStore.cachedPublicSettings),
|
||||
}))
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
// For custom pages, use the menu item's label instead of generic "自定义页面"
|
||||
if (route.name === 'CustomPage') {
|
||||
@@ -318,7 +328,7 @@ const pageTitle = computed(() => {
|
||||
?? (authStore.isAdmin ? adminSettingsStore.customMenuItems.find((item) => item.id === id) : undefined)
|
||||
if (menuItem?.label) return menuItem.label
|
||||
}
|
||||
const titleKey = route.meta.titleKey as string
|
||||
const titleKey = routeMetaKeys.value.titleKey
|
||||
if (titleKey) {
|
||||
return t(titleKey)
|
||||
}
|
||||
@@ -326,7 +336,7 @@ const pageTitle = computed(() => {
|
||||
})
|
||||
|
||||
const pageDescription = computed(() => {
|
||||
const descKey = route.meta.descriptionKey as string
|
||||
const descKey = routeMetaKeys.value.descriptionKey
|
||||
if (descKey) {
|
||||
return t(descKey)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
import { sanitizeSvg } from '@/utils/sanitize'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags'
|
||||
import { resolveSiteBillingMode } from '@/utils/siteBillingMode'
|
||||
import { useBatchImageAccess } from '@/composables/useBatchImageAccess'
|
||||
|
||||
interface NavItem {
|
||||
@@ -691,6 +692,19 @@ const ChevronDownIcon = {
|
||||
const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor)
|
||||
const flagPayment = makeSidebarFlag(FeatureFlags.payment)
|
||||
const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels)
|
||||
const flagSubscription = makeSidebarFlag(FeatureFlags.subscription)
|
||||
|
||||
// 购买入口文案随站点计费模式切换:仅充值 → 「充值」,仅订阅 → 「订阅」,否则「充值/订阅」。
|
||||
const purchaseNavLabel = computed(() => {
|
||||
switch (resolveSiteBillingMode(appStore.cachedPublicSettings)) {
|
||||
case 'recharge_only':
|
||||
return t('nav.recharge')
|
||||
case 'subscription_only':
|
||||
return t('nav.subscribe')
|
||||
default:
|
||||
return t('nav.buySubscription')
|
||||
}
|
||||
})
|
||||
const flagAffiliate = makeSidebarFlag(FeatureFlags.affiliate)
|
||||
const flagRiskControl = makeSidebarFlag(FeatureFlags.riskControl)
|
||||
const flagPluginManagement = makeSidebarFlag(FeatureFlags.pluginManagement)
|
||||
@@ -714,8 +728,8 @@ function buildSelfNavItems(withDashboard: boolean): NavItem[] {
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/available-channels', label: t('nav.availableChannels'), 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 },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true, featureFlag: flagSubscription },
|
||||
{ path: '/purchase', label: purchaseNavLabel.value, icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/affiliate', label: t('nav.affiliate'), icon: UsersIcon, hideInSimpleMode: true, featureFlag: flagAffiliate },
|
||||
@@ -776,7 +790,8 @@ const adminNavItems = computed((): NavItem[] => {
|
||||
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
|
||||
],
|
||||
},
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
// 「仅充值」站点连管理端的「订阅管理」入口也一并收起(路由本身不拦截)。
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true, featureFlag: flagSubscription },
|
||||
{ path: '/admin/accounts', label: t('nav.accounts'), icon: GlobeIcon },
|
||||
{ path: '/admin/plugins', label: t('nav.plugins'), icon: PluginIcon, featureFlag: flagPluginManagement },
|
||||
{ path: '/admin/announcements', label: t('nav.announcements'), icon: BellIcon },
|
||||
|
||||
@@ -62,3 +62,21 @@ describe('AppSidebar header styles', () => {
|
||||
expect(sidebarBrandBlockMatch?.[0]).not.toContain('overflow: hidden;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppSidebar subscription feature flag', () => {
|
||||
it('gates the My Subscriptions entry behind the subscription public-settings flag', () => {
|
||||
expect(componentSource).toContain('const flagSubscription = makeSidebarFlag(FeatureFlags.subscription)')
|
||||
expect(componentSource).toMatch(/path: '\/subscriptions'[^\n]*featureFlag: flagSubscription/)
|
||||
})
|
||||
|
||||
it('also hides the admin Subscription Management entry on recharge-only sites', () => {
|
||||
expect(componentSource).toMatch(/path: '\/admin\/subscriptions'[^\n]*featureFlag: flagSubscription/)
|
||||
})
|
||||
|
||||
it('derives the purchase entry label from the site billing mode', () => {
|
||||
expect(componentSource).toContain("import { resolveSiteBillingMode } from '@/utils/siteBillingMode'")
|
||||
expect(componentSource).toMatch(/case 'recharge_only':\s*return t\('nav\.recharge'\)/)
|
||||
expect(componentSource).toMatch(/case 'subscription_only':\s*return t\('nav\.subscribe'\)/)
|
||||
expect(componentSource).toMatch(/path: '\/purchase'[^\n]*label: purchaseNavLabel\.value/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,21 @@ export default {
|
||||
enabled: 'Enable Available Channels',
|
||||
enabledHint: 'When off, the sidebar entry is hidden and the endpoint returns an empty list.',
|
||||
},
|
||||
siteBillingMode: {
|
||||
title: 'Site Billing Mode',
|
||||
description: 'Controls which purchase options users see. Defaults to "Recharge & Subscription".',
|
||||
label: 'Purchase options',
|
||||
options: {
|
||||
rechargeAndSubscription: 'Recharge & Subscription',
|
||||
rechargeOnly: 'Recharge only',
|
||||
subscriptionOnly: 'Subscription only',
|
||||
},
|
||||
hints: {
|
||||
rechargeAndSubscription: 'Users can both top up their balance and buy subscription plans.',
|
||||
rechargeOnly: 'Hides "My Subscriptions", the purchase-page subscription tab, the header subscription badge and the usage billing-type filter; direct visits to "My Subscriptions" return to the dashboard. The admin sidebar also hides the "Subscription Management" entry (the page stays reachable by URL). Existing subscription billing and redeem-code subscriptions are unaffected.',
|
||||
subscriptionOnly: 'The purchase page only offers subscription plans and the sidebar entry reads "Subscription"; balance top-up orders are rejected. Redeem codes, affiliate payouts and other balance credits are unaffected.',
|
||||
},
|
||||
},
|
||||
modelPlaza: {
|
||||
title: 'Model Plaza',
|
||||
description: 'A public page showcasing available models and pricing by group. Disabled by default.',
|
||||
|
||||
@@ -197,6 +197,8 @@ export default {
|
||||
github: 'GitHub',
|
||||
mySubscriptions: 'My Subscriptions',
|
||||
buySubscription: 'Recharge / Subscription',
|
||||
recharge: 'Recharge',
|
||||
subscribe: 'Subscription',
|
||||
docs: 'Docs',
|
||||
myOrders: 'My Orders',
|
||||
orderManagement: 'Orders',
|
||||
|
||||
@@ -163,6 +163,7 @@ export default {
|
||||
usedQuota: 'Used Quota',
|
||||
resetNow: 'Resetting soon',
|
||||
subscriptionType: 'Subscription Type',
|
||||
billingType: 'Billing Type',
|
||||
subscriptionExpires: 'Subscription Expires',
|
||||
// Usage stat cells
|
||||
todayRequests: 'Today Requests',
|
||||
|
||||
@@ -63,6 +63,8 @@ export default {
|
||||
purchase: {
|
||||
title: 'Recharge / Subscription',
|
||||
description: 'Recharge balance or purchase subscription via the embedded page',
|
||||
rechargeDescription: 'Recharge balance via the embedded page',
|
||||
subscriptionDescription: 'Purchase subscription via the embedded page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notEnabledTitle: 'Feature not enabled',
|
||||
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
|
||||
@@ -385,6 +387,7 @@ export default {
|
||||
tabSubscribe: 'Subscribe',
|
||||
noPlans: 'No subscription plans available',
|
||||
notAvailable: 'Top-up is currently unavailable',
|
||||
billingUnavailable: 'Neither top-up nor subscriptions are currently available. Please contact the administrator.',
|
||||
confirmSubscription: 'Confirm Subscription',
|
||||
confirmCancel: 'Are you sure you want to cancel this order?',
|
||||
amountTooLow: 'Minimum amount is {min}',
|
||||
|
||||
@@ -45,6 +45,21 @@ export default {
|
||||
enabled: '启用可用渠道',
|
||||
enabledHint: '关闭后用户端侧边栏入口隐藏,接口返回空数组。',
|
||||
},
|
||||
siteBillingMode: {
|
||||
title: '站点类型',
|
||||
description: '决定用户端提供哪些购买方式。默认「充值 & 订阅」。',
|
||||
label: '购买方式',
|
||||
options: {
|
||||
rechargeAndSubscription: '充值 & 订阅',
|
||||
rechargeOnly: '仅充值',
|
||||
subscriptionOnly: '仅订阅',
|
||||
},
|
||||
hints: {
|
||||
rechargeAndSubscription: '用户端同时提供余额充值与订阅套餐。',
|
||||
rechargeOnly: '用户端隐藏「我的订阅」、购买页订阅套餐、顶栏订阅进度与用量页「计费类型」筛选,直接访问「我的订阅」会跳回仪表盘;管理端侧边栏同时隐藏「订阅管理」入口(页面仍可通过地址访问)。已有订阅的计费与兑换码发放的订阅不受影响。',
|
||||
subscriptionOnly: '用户端购买页只保留订阅套餐,侧边栏入口显示为「订阅」,余额充值下单会被拒绝;兑换码、返利等余额入账不受影响。',
|
||||
},
|
||||
},
|
||||
modelPlaza: {
|
||||
title: '模型广场',
|
||||
description: '以分组为单位向访客展示可用模型与价格的公开页面。默认关闭。',
|
||||
|
||||
@@ -197,6 +197,8 @@ export default {
|
||||
github: 'GitHub',
|
||||
mySubscriptions: '我的订阅',
|
||||
buySubscription: '充值/订阅',
|
||||
recharge: '充值',
|
||||
subscribe: '订阅',
|
||||
docs: '文档',
|
||||
myOrders: '我的订单',
|
||||
orderManagement: '订单管理',
|
||||
|
||||
@@ -163,6 +163,7 @@ export default {
|
||||
usedQuota: '已用额度',
|
||||
resetNow: '即将重置',
|
||||
subscriptionType: '订阅类型',
|
||||
billingType: '计费方式',
|
||||
subscriptionExpires: '订阅到期',
|
||||
// Usage stat cells
|
||||
todayRequests: '今日请求',
|
||||
|
||||
@@ -62,6 +62,8 @@ export default {
|
||||
purchase: {
|
||||
title: '充值/订阅',
|
||||
description: '通过内嵌页面完成充值/订阅',
|
||||
rechargeDescription: '通过内嵌页面完成充值',
|
||||
subscriptionDescription: '通过内嵌页面完成订阅',
|
||||
openInNewTab: '新窗口打开',
|
||||
notEnabledTitle: '该功能未开启',
|
||||
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
|
||||
@@ -409,6 +411,7 @@ export default {
|
||||
tabSubscribe: '订阅',
|
||||
noPlans: '暂无可用订阅套餐',
|
||||
notAvailable: '充值功能暂未开放',
|
||||
billingUnavailable: '充值与订阅均暂未开放,请联系管理员。',
|
||||
confirmSubscription: '确认订阅',
|
||||
confirmCancel: '确定要取消此订单吗?',
|
||||
amountTooLow: '最低金额为 {min}',
|
||||
|
||||
@@ -25,6 +25,7 @@ const appStore = vi.hoisted(() => ({
|
||||
cachedPublicSettings: null as null | {
|
||||
payment_enabled?: boolean
|
||||
risk_control_enabled?: boolean
|
||||
subscription_enabled?: boolean
|
||||
custom_menu_items?: []
|
||||
},
|
||||
fetchPublicSettings: vi.fn(),
|
||||
@@ -142,6 +143,7 @@ describe('feature route guard', () => {
|
||||
it.each([
|
||||
['payment', { requiresPayment: true }, '/purchase'],
|
||||
['risk control', { requiresRiskControl: true }, '/admin/risk-control'],
|
||||
['subscription', { requiresSubscription: true }, '/subscriptions'],
|
||||
])('does not treat a failed %s settings load as explicitly disabled', async (_name, meta, path) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.fetchPublicSettings.mockResolvedValue(null)
|
||||
@@ -162,6 +164,7 @@ describe('feature route guard', () => {
|
||||
{ risk_control_enabled: false },
|
||||
'/admin/settings',
|
||||
],
|
||||
['subscription', { requiresSubscription: true }, { subscription_enabled: false }, '/dashboard'],
|
||||
])('redirects when loaded settings explicitly disable %s', async (_name, meta, settings, target) => {
|
||||
authStore.isAdmin = meta.requiresRiskControl === true
|
||||
appStore.cachedPublicSettings = settings
|
||||
@@ -175,3 +178,35 @@ describe('feature route guard', () => {
|
||||
expect(next).toHaveBeenCalledWith(target)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription route guard (opt-out flag)', () => {
|
||||
beforeEach(() => {
|
||||
authStore.isAdmin = false
|
||||
authStore.isSimpleMode = false
|
||||
appStore.publicSettingsLoaded = true
|
||||
appStore.fetchPublicSettings.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing key', {}],
|
||||
['explicit true', { subscription_enabled: true }],
|
||||
])('lets /subscriptions through when the flag is %s', async (_name, settings) => {
|
||||
appStore.cachedPublicSettings = settings
|
||||
|
||||
const { navigation, next } = runGuard({ requiresSubscription: true }, '/subscriptions')
|
||||
await navigation
|
||||
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('sends admins to the admin dashboard when subscriptions are disabled', async () => {
|
||||
authStore.isAdmin = true
|
||||
appStore.cachedPublicSettings = { subscription_enabled: false }
|
||||
|
||||
const { navigation, next } = runGuard({ requiresSubscription: true }, '/subscriptions')
|
||||
await navigation
|
||||
|
||||
expect(next).toHaveBeenCalledWith('/admin/dashboard')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDocumentTitle, resolveRouteDocumentTitle } from '@/router/title'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PURCHASE_ROUTE_NAME, resolveDocumentTitle, resolveRouteDocumentTitle, resolveRouteMetaKeys } from '@/router/title'
|
||||
|
||||
// 语言包在测试环境是懒加载的,这里只提供本文件用到的几个 key,其余原样返回 key(触发 meta.title 回退)。
|
||||
vi.mock('@/i18n', () => {
|
||||
const messages: Record<string, string> = {
|
||||
'nav.recharge': '充值',
|
||||
'nav.subscribe': '订阅',
|
||||
'nav.buySubscription': '充值/订阅',
|
||||
}
|
||||
return { i18n: { global: { t: (key: string) => messages[key] ?? key } } }
|
||||
})
|
||||
|
||||
describe('resolveDocumentTitle', () => {
|
||||
it('路由存在标题时,使用“路由标题 - 站点名”格式', () => {
|
||||
@@ -47,3 +57,66 @@ describe('resolveRouteDocumentTitle', () => {
|
||||
])).toBe('账号调度器 - EzouAPI')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRouteMetaKeys', () => {
|
||||
const purchaseRoute = {
|
||||
name: PURCHASE_ROUTE_NAME,
|
||||
meta: { titleKey: 'nav.buySubscription', descriptionKey: 'purchase.description' }
|
||||
}
|
||||
|
||||
it('默认(充值 & 订阅或未知)沿用路由 meta 的标题/描述 key', () => {
|
||||
expect(resolveRouteMetaKeys(purchaseRoute)).toEqual({
|
||||
titleKey: 'nav.buySubscription',
|
||||
descriptionKey: 'purchase.description'
|
||||
})
|
||||
expect(resolveRouteMetaKeys(purchaseRoute, { billingMode: 'recharge_and_subscription' })).toEqual({
|
||||
titleKey: 'nav.buySubscription',
|
||||
descriptionKey: 'purchase.description'
|
||||
})
|
||||
})
|
||||
|
||||
it('仅充值时 /purchase 切换为纯充值文案', () => {
|
||||
expect(resolveRouteMetaKeys(purchaseRoute, { billingMode: 'recharge_only' })).toEqual({
|
||||
titleKey: 'nav.recharge',
|
||||
descriptionKey: 'purchase.rechargeDescription'
|
||||
})
|
||||
})
|
||||
|
||||
it('仅订阅时 /purchase 切换为纯订阅文案', () => {
|
||||
expect(resolveRouteMetaKeys(purchaseRoute, { billingMode: 'subscription_only' })).toEqual({
|
||||
titleKey: 'nav.subscribe',
|
||||
descriptionKey: 'purchase.subscriptionDescription'
|
||||
})
|
||||
})
|
||||
|
||||
it('站点类型不影响其他路由', () => {
|
||||
const route = { name: 'Subscriptions', meta: { titleKey: 'userSubscriptions.title' } }
|
||||
expect(resolveRouteMetaKeys(route, { billingMode: 'recharge_only' })).toEqual({
|
||||
titleKey: 'userSubscriptions.title',
|
||||
descriptionKey: undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRouteDocumentTitle 站点类型', () => {
|
||||
const purchaseRoute = {
|
||||
name: PURCHASE_ROUTE_NAME,
|
||||
params: {},
|
||||
meta: { title: 'Purchase Subscription', titleKey: 'nav.buySubscription' }
|
||||
}
|
||||
|
||||
it('仅充值时 document.title 不再带「订阅」', () => {
|
||||
const title = resolveRouteDocumentTitle(purchaseRoute, 'EzouAPI', [], { billingMode: 'recharge_only' })
|
||||
expect(title).toBe('充值 - EzouAPI')
|
||||
})
|
||||
|
||||
it('仅订阅时 document.title 只剩「订阅」', () => {
|
||||
const title = resolveRouteDocumentTitle(purchaseRoute, 'EzouAPI', [], { billingMode: 'subscription_only' })
|
||||
expect(title).toBe('订阅 - EzouAPI')
|
||||
})
|
||||
|
||||
it('充值 & 订阅时保留原标题', () => {
|
||||
const title = resolveRouteDocumentTitle(purchaseRoute, 'EzouAPI', [], { billingMode: 'recharge_and_subscription' })
|
||||
expect(title).toBe('充值/订阅 - EzouAPI')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -297,7 +297,8 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAdmin: false,
|
||||
title: 'My Subscriptions',
|
||||
titleKey: 'userSubscriptions.title',
|
||||
descriptionKey: 'userSubscriptions.description'
|
||||
descriptionKey: 'userSubscriptions.description',
|
||||
requiresSubscription: true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -907,7 +908,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
// 公共设置可能尚未加载(App.vue 的 onMounted 异步拉取晚于首次导航,且纯静态部署
|
||||
// 无 __APP_CONFIG__ 注入)。此时 cachedPublicSettings 为空会把 payment/risk_control
|
||||
// 误判为“未启用”而错误拦截,故这里先确保设置加载完成。
|
||||
if ((to.meta.requiresPayment || to.meta.requiresRiskControl) && !appStore.publicSettingsLoaded) {
|
||||
if ((to.meta.requiresPayment || to.meta.requiresRiskControl || to.meta.requiresSubscription) && !appStore.publicSettingsLoaded) {
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} catch (error) {
|
||||
@@ -935,6 +936,16 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 订阅功能是 opt-out 开关:只有显式 false 才拦截「我的订阅」页直达。
|
||||
if (
|
||||
to.meta.requiresSubscription &&
|
||||
appStore.publicSettingsLoaded &&
|
||||
appStore.cachedPublicSettings?.subscription_enabled === false
|
||||
) {
|
||||
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// 简易模式下限制访问某些页面
|
||||
if (authStore.isSimpleMode) {
|
||||
const restrictedPaths = [
|
||||
|
||||
Vendored
+6
@@ -55,6 +55,12 @@ declare module 'vue-router' {
|
||||
*/
|
||||
requiresRiskControl?: boolean
|
||||
|
||||
/**
|
||||
* 是否要求订阅功能开关(subscription_enabled,opt-out)未被显式关闭
|
||||
* @default false
|
||||
*/
|
||||
requiresSubscription?: boolean
|
||||
|
||||
/**
|
||||
* i18n key for the page title
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { i18n } from '@/i18n'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import type { CustomMenuItem } from '@/types'
|
||||
import type { SiteBillingMode } from '@/utils/siteBillingMode'
|
||||
|
||||
/**
|
||||
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
|
||||
@@ -23,16 +24,56 @@ export function resolveDocumentTitle(routeTitle: unknown, siteName?: string, tit
|
||||
return normalizedSiteName
|
||||
}
|
||||
|
||||
export interface RouteTitleOptions {
|
||||
/**
|
||||
* 站点计费模式(见 utils/billingMode.ts)。/purchase 的标题与描述随之切换:
|
||||
* 仅充值 → 「充值」,仅订阅 → 「订阅」,缺省/两者都有 → 「充值/订阅」。
|
||||
*/
|
||||
billingMode?: SiteBillingMode
|
||||
}
|
||||
|
||||
export interface RouteMetaKeys {
|
||||
titleKey?: string
|
||||
descriptionKey?: string
|
||||
}
|
||||
|
||||
/** /purchase 路由名,与 router/index.ts 中的声明保持一致。 */
|
||||
export const PURCHASE_ROUTE_NAME = 'PurchaseSubscription'
|
||||
|
||||
/**
|
||||
* 解析路由的 i18n 标题/描述 key。绝大多数路由直接取 meta;
|
||||
* /purchase 随站点计费模式切换文案,AppHeader 与 document.title 共用这一处判断。
|
||||
*/
|
||||
export function resolveRouteMetaKeys(
|
||||
route: Pick<RouteLocationNormalizedLoaded, 'name' | 'meta'>,
|
||||
options: RouteTitleOptions = {},
|
||||
): RouteMetaKeys {
|
||||
if (route.name === PURCHASE_ROUTE_NAME) {
|
||||
if (options.billingMode === 'recharge_only') {
|
||||
return { titleKey: 'nav.recharge', descriptionKey: 'purchase.rechargeDescription' }
|
||||
}
|
||||
if (options.billingMode === 'subscription_only') {
|
||||
return { titleKey: 'nav.subscribe', descriptionKey: 'purchase.subscriptionDescription' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
titleKey: typeof route.meta.titleKey === 'string' ? route.meta.titleKey : undefined,
|
||||
descriptionKey: typeof route.meta.descriptionKey === 'string' ? route.meta.descriptionKey : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveRouteDocumentTitle(
|
||||
route: Pick<RouteLocationNormalizedLoaded, 'name' | 'params' | 'meta'>,
|
||||
siteName: string | undefined,
|
||||
customMenuItems: CustomMenuItem[] = [],
|
||||
options: RouteTitleOptions = {},
|
||||
): string {
|
||||
const id = typeof route.params.id === 'string' ? route.params.id : ''
|
||||
const menuItem = route.name === 'CustomPage' && id
|
||||
? customMenuItems.find((item) => item.id === id)
|
||||
: undefined
|
||||
const menuTitle = menuItem?.label.trim()
|
||||
const { titleKey } = resolveRouteMetaKeys(route, options)
|
||||
|
||||
return resolveDocumentTitle(menuTitle || route.meta.title, siteName, menuTitle ? undefined : route.meta.titleKey as string)
|
||||
return resolveDocumentTitle(menuTitle || route.meta.title, siteName, menuTitle ? undefined : titleKey)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSe
|
||||
channel_monitor_enabled: true,
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
available_channels_enabled: false,
|
||||
subscription_enabled: true,
|
||||
payment_balance_disabled: false,
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
plugin_management_enabled: false,
|
||||
|
||||
@@ -371,6 +371,8 @@ export const useAppStore = defineStore('app', () => {
|
||||
channel_monitor_enabled: true,
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
available_channels_enabled: false,
|
||||
subscription_enabled: true,
|
||||
payment_balance_disabled: false,
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
plugin_management_enabled: false,
|
||||
|
||||
@@ -276,6 +276,10 @@ export interface PublicSettings {
|
||||
/** When true, user monitor hides the user ranking tab and /users payload. */
|
||||
channel_monitor_hide_user_ranking?: boolean
|
||||
available_channels_enabled: boolean
|
||||
/** When false, the whole user-facing subscription surface is hidden. Default true. */
|
||||
subscription_enabled: boolean
|
||||
/** Mirrors payment config BALANCE_PAYMENT_DISABLED; true = balance top-up closed (subscription-only site). */
|
||||
payment_balance_disabled: boolean
|
||||
model_plaza_enabled: boolean
|
||||
model_plaza_require_auth: boolean
|
||||
plugin_management_enabled: boolean
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { FeatureFlags, isFeatureFlagEnabled, makeSidebarFlag, resolveFeatureFlag } from '@/utils/featureFlags'
|
||||
import type { PublicSettings } from '@/types'
|
||||
|
||||
vi.mock('@/api/admin/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/auth', () => ({
|
||||
getPublicSettings: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('FeatureFlags.subscription', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
delete (window as any).__APP_CONFIG__
|
||||
})
|
||||
|
||||
it('reads subscription_enabled as an opt-out flag: visible before settings load', () => {
|
||||
expect(FeatureFlags.subscription.key).toBe('subscription_enabled')
|
||||
expect(FeatureFlags.subscription.mode).toBe('opt-out')
|
||||
expect(useAppStore().cachedPublicSettings).toBeNull()
|
||||
expect(isFeatureFlagEnabled(FeatureFlags.subscription)).toBe(true)
|
||||
})
|
||||
|
||||
it('hides only when the backend explicitly sends false', () => {
|
||||
const store = useAppStore()
|
||||
const sidebarFlag = makeSidebarFlag(FeatureFlags.subscription)
|
||||
|
||||
store.cachedPublicSettings = { subscription_enabled: false } as PublicSettings
|
||||
expect(sidebarFlag()).toBe(false)
|
||||
|
||||
store.cachedPublicSettings = { subscription_enabled: true } as PublicSettings
|
||||
expect(sidebarFlag()).toBe(true)
|
||||
|
||||
store.cachedPublicSettings = {} as PublicSettings
|
||||
expect(sidebarFlag()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFeatureFlag', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('reads an explicit boolean from the given settings object', () => {
|
||||
expect(resolveFeatureFlag({ subscription_enabled: false } as PublicSettings, FeatureFlags.subscription)).toBe(false)
|
||||
expect(resolveFeatureFlag({ subscription_enabled: true } as PublicSettings, FeatureFlags.subscription)).toBe(true)
|
||||
expect(resolveFeatureFlag({ available_channels_enabled: true } as PublicSettings, FeatureFlags.availableChannels)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the declared mode when settings are missing or the key is absent', () => {
|
||||
expect(resolveFeatureFlag(undefined, FeatureFlags.subscription)).toBe(true)
|
||||
expect(resolveFeatureFlag(null, FeatureFlags.subscription)).toBe(true)
|
||||
expect(resolveFeatureFlag({} as PublicSettings, FeatureFlags.subscription)).toBe(true)
|
||||
expect(resolveFeatureFlag({} as PublicSettings, FeatureFlags.availableChannels)).toBe(false)
|
||||
})
|
||||
|
||||
it('backs isFeatureFlagEnabled with the same resolution', () => {
|
||||
useAppStore().cachedPublicSettings = { subscription_enabled: false } as PublicSettings
|
||||
expect(isFeatureFlagEnabled(FeatureFlags.subscription)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
SITE_BILLING_MODES,
|
||||
billingModeToSettings,
|
||||
resolveSiteBillingMode,
|
||||
} from '@/utils/siteBillingMode'
|
||||
|
||||
describe('resolveSiteBillingMode', () => {
|
||||
it('defaults to recharge & subscription when settings are missing or empty', () => {
|
||||
expect(resolveSiteBillingMode(undefined)).toBe('recharge_and_subscription')
|
||||
expect(resolveSiteBillingMode(null)).toBe('recharge_and_subscription')
|
||||
expect(resolveSiteBillingMode({})).toBe('recharge_and_subscription')
|
||||
})
|
||||
|
||||
it('maps the two backend switches onto the three modes', () => {
|
||||
expect(resolveSiteBillingMode({ subscription_enabled: true, payment_balance_disabled: false })).toBe('recharge_and_subscription')
|
||||
expect(resolveSiteBillingMode({ subscription_enabled: false, payment_balance_disabled: false })).toBe('recharge_only')
|
||||
expect(resolveSiteBillingMode({ subscription_enabled: true, payment_balance_disabled: true })).toBe('subscription_only')
|
||||
})
|
||||
|
||||
it('treats the API-only "both disabled" combination as recharge only', () => {
|
||||
expect(resolveSiteBillingMode({ subscription_enabled: false, payment_balance_disabled: true })).toBe('recharge_only')
|
||||
})
|
||||
|
||||
it('round-trips every mode through billingModeToSettings', () => {
|
||||
for (const mode of SITE_BILLING_MODES) {
|
||||
expect(resolveSiteBillingMode(billingModeToSettings(mode))).toBe(mode)
|
||||
}
|
||||
})
|
||||
|
||||
it('never produces the both-disabled combination', () => {
|
||||
for (const mode of SITE_BILLING_MODES) {
|
||||
const settings = billingModeToSettings(mode)
|
||||
expect(settings.subscription_enabled || !settings.payment_balance_disabled).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -104,6 +104,11 @@ export const FeatureFlags = {
|
||||
mode: 'opt-in',
|
||||
label: 'Available Channels',
|
||||
}),
|
||||
subscription: defineFlag({
|
||||
key: 'subscription_enabled',
|
||||
mode: 'opt-out',
|
||||
label: 'Subscription',
|
||||
}),
|
||||
modelPlaza: defineFlag({
|
||||
key: 'model_plaza_enabled',
|
||||
mode: 'opt-in',
|
||||
@@ -140,9 +145,19 @@ export type RegisteredFeatureFlag = keyof typeof FeatureFlags
|
||||
*/
|
||||
export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
|
||||
const appStore = useAppStore()
|
||||
const raw = appStore.cachedPublicSettings?.[flag.key] as
|
||||
| boolean
|
||||
| undefined
|
||||
return resolveFeatureFlag(appStore.cachedPublicSettings, flag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure resolver behind `isFeatureFlagEnabled`. Use it when the caller already
|
||||
* holds a settings object (e.g. a store instance from `@/stores`) and should
|
||||
* not reach for `useAppStore` itself — keeps views testable without Pinia.
|
||||
*/
|
||||
export function resolveFeatureFlag(
|
||||
settings: Partial<PublicSettings> | null | undefined,
|
||||
flag: FeatureFlagDefinition,
|
||||
): boolean {
|
||||
const raw = settings?.[flag.key] as boolean | undefined
|
||||
if (typeof raw === 'boolean') return raw
|
||||
// Settings not yet loaded → fall back to the flag's declared mode:
|
||||
// opt-out → visible by default, opt-in → hidden by default.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PublicSettings } from '@/types'
|
||||
import { FeatureFlags, resolveFeatureFlag } from '@/utils/featureFlags'
|
||||
|
||||
/**
|
||||
* 站点计费模式(后台「站点类型」单选),由两个后端开关派生:
|
||||
* - `subscription_enabled`(opt-out,缺省视为开启):用户端订阅面(侧边栏、购买页订阅 tab、顶栏徽章、/subscriptions)
|
||||
* - `payment_balance_disabled`(strict true):支付配置里的 BALANCE_PAYMENT_DISABLED,关闭余额充值下单
|
||||
*
|
||||
* 两者都关闭是接口层可表达但 UI 不提供的组合,这里归为「仅充值」;后台选择器保存时会把余额充值重新打开。
|
||||
*/
|
||||
export type SiteBillingMode = 'recharge_and_subscription' | 'recharge_only' | 'subscription_only'
|
||||
|
||||
export const SITE_BILLING_MODES: readonly SiteBillingMode[] = [
|
||||
'recharge_and_subscription',
|
||||
'recharge_only',
|
||||
'subscription_only',
|
||||
]
|
||||
|
||||
/** i18n 子键(admin.settings.features.siteBillingMode.options / hints)。 */
|
||||
export const SITE_BILLING_MODE_I18N_KEYS: Record<SiteBillingMode, string> = {
|
||||
recharge_and_subscription: 'rechargeAndSubscription',
|
||||
recharge_only: 'rechargeOnly',
|
||||
subscription_only: 'subscriptionOnly',
|
||||
}
|
||||
|
||||
export interface BillingModeSettings {
|
||||
subscription_enabled?: boolean
|
||||
payment_balance_disabled?: boolean
|
||||
}
|
||||
|
||||
export function resolveSiteBillingMode(settings: BillingModeSettings | null | undefined): SiteBillingMode {
|
||||
const subscriptionEnabled = resolveFeatureFlag(
|
||||
{ subscription_enabled: settings?.subscription_enabled } as Partial<PublicSettings>,
|
||||
FeatureFlags.subscription,
|
||||
)
|
||||
if (!subscriptionEnabled) return 'recharge_only'
|
||||
if (settings?.payment_balance_disabled === true) return 'subscription_only'
|
||||
return 'recharge_and_subscription'
|
||||
}
|
||||
|
||||
export function billingModeToSettings(mode: SiteBillingMode): Required<BillingModeSettings> {
|
||||
switch (mode) {
|
||||
case 'recharge_only':
|
||||
return { subscription_enabled: false, payment_balance_disabled: false }
|
||||
case 'subscription_only':
|
||||
return { subscription_enabled: true, payment_balance_disabled: true }
|
||||
default:
|
||||
return { subscription_enabled: true, payment_balance_disabled: false }
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { FeatureFlags, resolveFeatureFlag } from '@/utils/featureFlags'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildGatewayUrl } from '@/api/client'
|
||||
@@ -428,6 +429,7 @@ import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const subscriptionFeatureEnabled = computed(() => resolveFeatureFlag(appStore.cachedPublicSettings, FeatureFlags.subscription))
|
||||
|
||||
// ==================== Site Settings (same as HomeView) ====================
|
||||
|
||||
@@ -728,7 +730,9 @@ const detailRows = computed<DetailRow[]>(() => {
|
||||
} else {
|
||||
rows.push({
|
||||
iconBg: 'bg-emerald-500/10', iconColor: 'text-emerald-500', iconSvg: ICON_CHECK,
|
||||
label: t('keyUsage.subscriptionType'), value: data.planName || t('keyUsage.walletBalance'), valueClass: '',
|
||||
// 订阅功能关闭后这一行只会是「钱包余额」,标签改用不带「订阅」字样的「计费方式」。
|
||||
label: subscriptionFeatureEnabled.value ? t('keyUsage.subscriptionType') : t('keyUsage.billingType'),
|
||||
value: data.planName || t('keyUsage.walletBalance'), valueClass: '',
|
||||
})
|
||||
|
||||
if (data.subscription) {
|
||||
|
||||
@@ -47,6 +47,7 @@ const messages: Record<string, string> = {
|
||||
'keyUsage.remainingQuota': 'Remaining Quota',
|
||||
'keyUsage.usedQuota': 'Used Quota',
|
||||
'keyUsage.subscriptionType': 'Subscription Type',
|
||||
'keyUsage.billingType': 'Billing Type',
|
||||
'keyUsage.todayRequests': 'Today Requests',
|
||||
'keyUsage.todayInputTokens': 'Today Input',
|
||||
'keyUsage.todayOutputTokens': 'Today Output',
|
||||
@@ -83,9 +84,15 @@ vi.mock('vue-i18n', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const appStoreState = vi.hoisted(() => ({
|
||||
cachedPublicSettings: null as Record<string, unknown> | null,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
cachedPublicSettings: null,
|
||||
get cachedPublicSettings() {
|
||||
return appStoreState.cachedPublicSettings
|
||||
},
|
||||
siteName: 'Sub2API',
|
||||
siteLogo: '',
|
||||
docUrl: '',
|
||||
@@ -232,3 +239,70 @@ describe('KeyUsageView daily detail', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('KeyUsageView subscription feature flag', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue({ matches: false }),
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => window.setTimeout(() => cb(0), 0))
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mode: 'wallet',
|
||||
isValid: true,
|
||||
status: 'active',
|
||||
balance: 5.5,
|
||||
usage: {
|
||||
today: { requests: 0, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, actual_cost: 0 },
|
||||
total: { requests: 0, input_tokens: 0, output_tokens: 0, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 0, actual_cost: 0 },
|
||||
rpm: 0,
|
||||
tpm: 0,
|
||||
},
|
||||
daily_usage: [],
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
appStoreState.cachedPublicSettings = null
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function mountAndQuery() {
|
||||
const wrapper = mount(KeyUsageView, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: { template: '<a><slot /></a>' },
|
||||
LocaleSwitcher: true,
|
||||
Icon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('input').setValue('sk-test-key')
|
||||
await wrapper.find('input').trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('labels the wallet row "Subscription Type" while subscriptions are enabled', async () => {
|
||||
const wrapper = await mountAndQuery()
|
||||
|
||||
expect(wrapper.text()).toContain('Subscription Type')
|
||||
expect(wrapper.text()).toContain('Wallet Balance')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('drops the "Subscription" wording from the wallet row when subscriptions are disabled', async () => {
|
||||
appStoreState.cachedPublicSettings = { subscription_enabled: false }
|
||||
const wrapper = await mountAndQuery()
|
||||
|
||||
expect(wrapper.text()).toContain('Billing Type')
|
||||
expect(wrapper.text()).not.toContain('Subscription Type')
|
||||
expect(wrapper.text()).toContain('Wallet Balance')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7227,6 +7227,36 @@
|
||||
</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.siteBillingMode.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.siteBillingMode.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.features.siteBillingMode.label') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ siteBillingModeHint }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full shrink-0 sm:w-56">
|
||||
<Select
|
||||
:modelValue="siteBillingMode"
|
||||
:options="siteBillingModeOptions"
|
||||
@update:modelValue="siteBillingMode = $event as SiteBillingMode"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
@@ -8814,7 +8844,14 @@ import type {
|
||||
import type { ProviderInstance } from "@/types/payment";
|
||||
import AppLayout from "@/components/layout/AppLayout.vue";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import Select from "@/components/common/Select.vue";
|
||||
import Select, { type SelectOption } from "@/components/common/Select.vue";
|
||||
import {
|
||||
SITE_BILLING_MODES,
|
||||
SITE_BILLING_MODE_I18N_KEYS,
|
||||
billingModeToSettings,
|
||||
resolveSiteBillingMode,
|
||||
type SiteBillingMode,
|
||||
} from "@/utils/siteBillingMode";
|
||||
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
|
||||
import PaymentProviderList from "@/components/payment/PaymentProviderList.vue";
|
||||
import PaymentProviderDialog from "@/components/payment/PaymentProviderDialog.vue";
|
||||
@@ -9814,6 +9851,8 @@ const form = reactive<SettingsForm>({
|
||||
channel_monitor_hide_user_ranking: false,
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: false,
|
||||
// Subscription feature switch (user sidebar "My Subscriptions" entry)
|
||||
subscription_enabled: true,
|
||||
// Model Plaza feature switches + description
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
@@ -11021,6 +11060,24 @@ function findDuplicateDefaultSubscription(
|
||||
});
|
||||
}
|
||||
|
||||
// 站点类型:由 subscription_enabled 与 payment_balance_disabled 两个开关派生的单选,
|
||||
// 保存时同时写回两者,避免出现「既无充值也无订阅」的组合。
|
||||
const siteBillingModeOptions = computed<SelectOption[]>(() =>
|
||||
SITE_BILLING_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
label: t(`admin.settings.features.siteBillingMode.options.${SITE_BILLING_MODE_I18N_KEYS[mode]}`),
|
||||
})),
|
||||
);
|
||||
const siteBillingMode = computed<SiteBillingMode>({
|
||||
get: () => resolveSiteBillingMode(form),
|
||||
set: (mode) => {
|
||||
Object.assign(form, billingModeToSettings(mode));
|
||||
},
|
||||
});
|
||||
const siteBillingModeHint = computed(() =>
|
||||
t(`admin.settings.features.siteBillingMode.hints.${SITE_BILLING_MODE_I18N_KEYS[siteBillingMode.value]}`),
|
||||
);
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
@@ -11478,6 +11535,8 @@ async function saveSettings() {
|
||||
channel_monitor_hide_user_ranking: Boolean(form.channel_monitor_hide_user_ranking),
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: form.available_channels_enabled,
|
||||
// Subscription feature switch
|
||||
subscription_enabled: form.subscription_enabled,
|
||||
// Model Plaza feature switches + description
|
||||
model_plaza_enabled: form.model_plaza_enabled,
|
||||
model_plaza_require_auth: form.model_plaza_require_auth,
|
||||
|
||||
@@ -33,8 +33,12 @@
|
||||
</template>
|
||||
<!-- Tab content (select phase) -->
|
||||
<template v-else>
|
||||
<!-- Neither top-up nor subscriptions available (balance recharge disabled via API while subscriptions are off) -->
|
||||
<div v-if="tabs.length === 0" class="card py-16 text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.billingUnavailable') }}</p>
|
||||
</div>
|
||||
<!-- Top-up Tab -->
|
||||
<template v-if="activeTab === 'recharge'">
|
||||
<template v-else-if="activeTab === 'recharge'">
|
||||
<!-- Recharge Account Card -->
|
||||
<div class="card p-5">
|
||||
<p class="text-xs font-medium text-gray-400 dark:text-gray-500">{{ t('payment.rechargeAccount') }}</p>
|
||||
@@ -266,6 +270,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
import { useSubscriptionStore } from '@/stores/subscriptions'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { FeatureFlags, resolveFeatureFlag } from '@/utils/featureFlags'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
@@ -512,13 +517,26 @@ const renderedHelpText = computed(() => DOMPurify.sanitize(
|
||||
marked.parse(checkout.value.help_text || '', { async: false, gfm: true, breaks: false }),
|
||||
))
|
||||
|
||||
// 订阅功能开关(public settings 的 subscription_enabled,opt-out)。关闭后购买页只保留充值:
|
||||
// 不再渲染「订阅」tab,只剩单个 tab 时顶部切换器也随之隐藏。
|
||||
const subscriptionEnabled = computed(() => resolveFeatureFlag(appStore.cachedPublicSettings, FeatureFlags.subscription))
|
||||
|
||||
const tabs = computed(() => {
|
||||
const result: { key: 'recharge' | 'subscription'; label: string }[] = []
|
||||
if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') })
|
||||
result.push({ key: 'subscription', label: t('payment.tabSubscribe') })
|
||||
if (subscriptionEnabled.value) result.push({ key: 'subscription', label: t('payment.tabSubscribe') })
|
||||
return result
|
||||
})
|
||||
|
||||
// tab 列表随 checkout(balance_disabled)与订阅开关变化。当前 tab 不在列表里时收敛到第一个可用 tab,
|
||||
// 两个方向都覆盖:关闭订阅 → 回到充值;仅订阅站点重新打开订阅 → 进入订阅。列表为空时模板展示不可用提示。
|
||||
watch(tabs, (available) => {
|
||||
if (available.some((tab) => tab.key === activeTab.value)) return
|
||||
const leavingSubscription = activeTab.value === 'subscription'
|
||||
activeTab.value = available[0]?.key ?? 'recharge'
|
||||
if (leavingSubscription) selectedPlan.value = null
|
||||
})
|
||||
|
||||
const visibleMethods = computed(() => getVisibleMethods(checkout.value.methods))
|
||||
const enabledMethods = computed(() => Object.keys(visibleMethods.value))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
@@ -1142,11 +1160,9 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
await resumeWechatPaymentFromQuery()
|
||||
if (checkout.value.balance_disabled) {
|
||||
activeTab.value = 'subscription'
|
||||
}
|
||||
// Handle renewal navigation: ?tab=subscription&group=123
|
||||
if (route.query.tab === 'subscription') {
|
||||
// balance_disabled → the tabs watcher above moves activeTab to the subscription tab (when enabled).
|
||||
// Handle renewal navigation: ?tab=subscription&group=123 (ignored when subscriptions are disabled)
|
||||
if (route.query.tab === 'subscription' && subscriptionEnabled.value) {
|
||||
activeTab.value = 'subscription'
|
||||
if (route.query.group) {
|
||||
const groupId = Number(route.query.group)
|
||||
@@ -1161,7 +1177,9 @@ onMounted(async () => {
|
||||
}
|
||||
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
|
||||
finally { loading.value = false }
|
||||
// Fetch active subscriptions (uses cache, non-blocking)
|
||||
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
|
||||
// Fetch active subscriptions (uses cache, non-blocking); skipped when the subscription feature is off
|
||||
if (subscriptionEnabled.value) {
|
||||
subscriptionStore.fetchActiveSubscriptions().catch(() => {})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<label class="input-label">{{ t('usage.compactionFilter') }}</label>
|
||||
<Select v-model="filters.native_compaction_v2" :options="compactionOptions" @change="applyFilters" />
|
||||
</div>
|
||||
<div class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<div v-if="subscriptionFeatureEnabled" class="w-full sm:w-auto sm:min-w-[200px]">
|
||||
<label class="input-label">{{ t('admin.usage.billingType') }}</label>
|
||||
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="applyFilters" />
|
||||
</div>
|
||||
@@ -222,6 +222,7 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { FeatureFlags, resolveFeatureFlag } from '@/utils/featureFlags'
|
||||
import { keysAPI, usageAPI, userGroupsAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
@@ -391,6 +392,8 @@ const compactionOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('usage.allCompactionTypes') },
|
||||
{ value: true, label: t('usage.compactionOnly') },
|
||||
])
|
||||
// 订阅功能关闭后只剩余额计费,「计费类型」筛选(余额/订阅)失去意义,整块隐藏。
|
||||
const subscriptionFeatureEnabled = computed(() => resolveFeatureFlag(appStore.cachedPublicSettings, FeatureFlags.subscription))
|
||||
const billingTypeOptions = computed<SelectOption[]>(() => [
|
||||
{ value: null, label: t('admin.usage.allBillingTypes') },
|
||||
{ value: 0, label: t('admin.usage.billingTypeBalance') },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import PaymentView from '../PaymentView.vue'
|
||||
import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow'
|
||||
@@ -26,6 +26,11 @@ const showWarning = vi.hoisted(() => vi.fn())
|
||||
const getCheckoutInfo = vi.hoisted(() => vi.fn())
|
||||
const bridgeInvoke = vi.hoisted(() => vi.fn())
|
||||
const translate = vi.hoisted(() => vi.fn((key: string) => key))
|
||||
// Public settings live in a reactive holder so tests can flip feature flags after mount
|
||||
// and exercise the watchers that react to them.
|
||||
const appStoreState = vi.hoisted(() => ({
|
||||
setPublicSettings: (_value: Record<string, unknown> | undefined) => {},
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
|
||||
@@ -73,13 +78,23 @@ vi.mock('@/stores/subscriptions', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showInfo,
|
||||
showWarning,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/stores', async () => {
|
||||
const { reactive } = await import('vue')
|
||||
const state = reactive({ cachedPublicSettings: undefined as Record<string, unknown> | undefined })
|
||||
appStoreState.setPublicSettings = (value) => {
|
||||
state.cachedPublicSettings = value
|
||||
}
|
||||
return {
|
||||
useAppStore: () => ({
|
||||
showError,
|
||||
showInfo,
|
||||
showWarning,
|
||||
get cachedPublicSettings() {
|
||||
return state.cachedPublicSettings
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/api/payment', () => ({
|
||||
paymentAPI: {
|
||||
@@ -804,3 +819,71 @@ describe('PaymentView WeChat JSAPI flow', () => {
|
||||
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toContain('weixin://wxpay/bizpayurl?pr=fallback-native')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PaymentView subscription feature flag', () => {
|
||||
afterEach(() => {
|
||||
appStoreState.setPublicSettings(undefined)
|
||||
})
|
||||
|
||||
function tabLabels(wrapper: Awaited<ReturnType<typeof mountSubscriptionPlanList>>) {
|
||||
return wrapper
|
||||
.findAll('button')
|
||||
.map((button) => button.text())
|
||||
.filter((text) => text === 'payment.tabTopUp' || text === 'payment.tabSubscribe')
|
||||
}
|
||||
|
||||
it('keeps the top-up / subscribe switcher when subscription_enabled is absent (opt-out default)', async () => {
|
||||
const wrapper = await mountSubscriptionPlanList(2)
|
||||
|
||||
expect(tabLabels(wrapper)).toEqual(['payment.tabTopUp', 'payment.tabSubscribe'])
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops the subscribe tab, hides the switcher and ignores ?tab=subscription when subscriptions are disabled', async () => {
|
||||
appStoreState.setPublicSettings({ subscription_enabled: false })
|
||||
const wrapper = await mountSubscriptionPlanList(2)
|
||||
|
||||
expect(tabLabels(wrapper)).toEqual([])
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard)).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('payment.rechargeAccount')
|
||||
})
|
||||
|
||||
it('shows an unavailable notice instead of a doomed top-up form when balance recharge is disabled too', async () => {
|
||||
appStoreState.setPublicSettings({ subscription_enabled: false })
|
||||
const wrapper = await mountSubscriptionConfirm({ checkout: { balance_disabled: true } })
|
||||
|
||||
expect(tabLabels(wrapper)).toEqual([])
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard)).toHaveLength(0)
|
||||
expect(wrapper.text()).not.toContain('payment.confirmSubscription')
|
||||
expect(wrapper.text()).not.toContain('payment.rechargeAccount')
|
||||
expect(wrapper.text()).toContain('payment.billingUnavailable')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('falls back from the subscribe tab to top-up when the flag flips off after mount', async () => {
|
||||
const wrapper = await mountSubscriptionPlanList(2)
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard)).toHaveLength(2)
|
||||
|
||||
appStoreState.setPublicSettings({ subscription_enabled: false })
|
||||
await flushPromises()
|
||||
|
||||
expect(tabLabels(wrapper)).toEqual([])
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard)).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('payment.rechargeAccount')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('enters the subscribe tab when a subscription-only site turns subscriptions back on', async () => {
|
||||
appStoreState.setPublicSettings({ subscription_enabled: false })
|
||||
const wrapper = await mountSubscriptionConfirm({ checkout: { balance_disabled: true } })
|
||||
expect(wrapper.text()).toContain('payment.billingUnavailable')
|
||||
|
||||
appStoreState.setPublicSettings({ subscription_enabled: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('payment.billingUnavailable')
|
||||
expect(wrapper.text()).not.toContain('payment.rechargeAccount')
|
||||
expect(wrapper.findAllComponents(SubscriptionPlanCard).length).toBeGreaterThan(0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
import UsageView from '../UsageView.vue'
|
||||
@@ -89,10 +89,16 @@ vi.mock('@/api', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const appStoreState = vi.hoisted(() => ({
|
||||
cachedPublicSettings: { allow_user_view_error_requests: true } as Record<string, unknown>,
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError, showWarning, showSuccess, showInfo,
|
||||
cachedPublicSettings: { allow_user_view_error_requests: true },
|
||||
get cachedPublicSettings() {
|
||||
return appStoreState.cachedPublicSettings
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -460,3 +466,35 @@ describe('user UsageView', () => {
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('UsageView subscription feature flag', () => {
|
||||
afterEach(() => {
|
||||
appStoreState.cachedPublicSettings = { allow_user_view_error_requests: true }
|
||||
})
|
||||
|
||||
function billingTypeSelect(wrapper: ReturnType<typeof mountUsageView>) {
|
||||
return wrapper.findAllComponents(Select).find((select) =>
|
||||
select.props('options').some((option: SelectOption) => option.label === 'Subscription')
|
||||
)
|
||||
}
|
||||
|
||||
it('offers the balance / subscription billing-type filter by default', async () => {
|
||||
const wrapper = mountUsageView()
|
||||
await flushPromises()
|
||||
|
||||
expect(billingTypeSelect(wrapper)).toBeDefined()
|
||||
expect(wrapper.text()).toContain('Billing type')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides the billing-type filter entirely when subscriptions are disabled', async () => {
|
||||
appStoreState.cachedPublicSettings = { allow_user_view_error_requests: true, subscription_enabled: false }
|
||||
|
||||
const wrapper = mountUsageView()
|
||||
await flushPromises()
|
||||
|
||||
expect(billingTypeSelect(wrapper)).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('Billing type')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user