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:
feeeei
2026-09-11 00:50:17 +08:00
parent 98d86915be
commit 9d475f9edb
46 changed files with 938 additions and 47 deletions
@@ -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,
+11 -1
View File
@@ -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)
+10
View File
@@ -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
View File
@@ -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) {
+6
View File
@@ -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)
})
+14 -4
View File
@@ -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)
}
+18 -3
View File
@@ -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.',
+2
View File
@@ -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',
+1
View File
@@ -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',
+3
View File
@@ -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: '以分组为单位向访客展示可用模型与价格的公开页面。默认关闭。',
+2
View File
@@ -197,6 +197,8 @@ export default {
github: 'GitHub',
mySubscriptions: '我的订阅',
buySubscription: '充值/订阅',
recharge: '充值',
subscribe: '订阅',
docs: '文档',
myOrders: '我的订单',
orderManagement: '订单管理',
+1
View File
@@ -163,6 +163,7 @@ export default {
usedQuota: '已用额度',
resetNow: '即将重置',
subscriptionType: '订阅类型',
billingType: '计费方式',
subscriptionExpires: '订阅到期',
// Usage stat cells
todayRequests: '今日请求',
+3
View File
@@ -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')
})
})
+75 -2
View File
@@ -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')
})
})
+13 -2
View File
@@ -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 = [
+6
View File
@@ -55,6 +55,12 @@ declare module 'vue-router' {
*/
requiresRiskControl?: boolean
/**
* 是否要求订阅功能开关(subscription_enabled,opt-out)未被显式关闭
* @default false
*/
requiresSubscription?: boolean
/**
* i18n key for the page title
*/
+42 -1
View File
@@ -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,
+2
View File
@@ -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,
+4
View File
@@ -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)
}
})
})
+18 -3
View File
@@ -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.
+50
View File
@@ -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 }
}
}
+5 -1
View File
@@ -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()
})
})
+60 -1
View File
@@ -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,
+27 -9
View File
@@ -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>
+4 -1
View File
@@ -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()
})
})