feat(settings): add default subscriptions for new users

- add default subscriptions to admin settings

- auto-assign subscriptions on register and admin user creation

- add validation/tests and align settings UI with subscription selector patterns
This commit is contained in:
PMExtra
2026-03-02 03:41:50 +08:00
parent 3613695f91
commit 7e02082209
21 changed files with 730 additions and 30 deletions

View File

@@ -5,6 +5,11 @@
import { apiClient } from '../client'
export interface DefaultSubscriptionSetting {
group_id: number
validity_days: number
}
/**
* System settings interface
*/
@@ -20,6 +25,7 @@ export interface SystemSettings {
// Default settings
default_balance: number
default_concurrency: number
default_subscriptions: DefaultSubscriptionSetting[]
// OEM settings
site_name: string
site_logo: string
@@ -81,6 +87,7 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean // TOTP 双因素认证
default_balance?: number
default_concurrency?: number
default_subscriptions?: DefaultSubscriptionSetting[]
site_name?: string
site_logo?: string
site_subtitle?: string

View File

@@ -3555,7 +3555,15 @@ export default {
defaultBalance: 'Default Balance',
defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users'
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
defaultSubscriptions: 'Default Subscriptions',
defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered',
addDefaultSubscription: 'Add Default Subscription',
defaultSubscriptionsEmpty: 'No default subscriptions configured.',
defaultSubscriptionsDuplicate:
'Duplicate subscription group: {groupId}. Each group can only appear once.',
subscriptionGroup: 'Subscription Group',
subscriptionValidityDays: 'Validity (days)'
},
claudeCode: {
title: 'Claude Code Settings',

View File

@@ -3725,7 +3725,14 @@ export default {
defaultBalance: '默认余额',
defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数'
defaultConcurrencyHint: '新用户的最大并发请求数',
defaultSubscriptions: '默认订阅列表',
defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅',
addDefaultSubscription: '添加默认订阅',
defaultSubscriptionsEmpty: '未配置默认订阅。新用户不会自动获得订阅套餐。',
defaultSubscriptionsDuplicate: '默认订阅存在重复分组:{groupId}。每个分组只能出现一次。',
subscriptionGroup: '订阅分组',
subscriptionValidityDays: '有效期(天)'
},
claudeCode: {
title: 'Claude Code 设置',

View File

@@ -579,7 +579,7 @@
{{ t('admin.settings.defaults.description') }}
</p>
</div>
<div class="p-6">
<div class="space-y-6 p-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -613,6 +613,98 @@
</p>
</div>
</div>
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.defaults.defaultSubscriptions') }}
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.defaults.defaultSubscriptionsHint') }}
</p>
</div>
<button
type="button"
class="btn btn-secondary btn-sm"
@click="addDefaultSubscription"
:disabled="subscriptionGroups.length === 0"
>
{{ t('admin.settings.defaults.addDefaultSubscription') }}
</button>
</div>
<div
v-if="form.default_subscriptions.length === 0"
class="rounded border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 dark:border-dark-600 dark:text-gray-400"
>
{{ t('admin.settings.defaults.defaultSubscriptionsEmpty') }}
</div>
<div v-else class="space-y-3">
<div
v-for="(item, index) in form.default_subscriptions"
:key="`default-sub-${index}`"
class="grid grid-cols-1 gap-3 rounded border border-gray-200 p-3 md:grid-cols-[1fr_160px_auto] dark:border-dark-600"
>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.settings.defaults.subscriptionGroup') }}
</label>
<Select
v-model="item.group_id"
class="default-sub-group-select"
:options="defaultSubscriptionGroupOptions"
:placeholder="t('admin.settings.defaults.subscriptionGroup')"
>
<template #selected="{ option }">
<GroupBadge
v-if="option"
:name="(option as unknown as DefaultSubscriptionGroupOption).label"
:platform="(option as unknown as DefaultSubscriptionGroupOption).platform"
:subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
:rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate"
/>
<span v-else class="text-gray-400">
{{ t('admin.settings.defaults.subscriptionGroup') }}
</span>
</template>
<template #option="{ option, selected }">
<GroupOptionItem
:name="(option as unknown as DefaultSubscriptionGroupOption).label"
:platform="(option as unknown as DefaultSubscriptionGroupOption).platform"
:subscription-type="(option as unknown as DefaultSubscriptionGroupOption).subscriptionType"
:rate-multiplier="(option as unknown as DefaultSubscriptionGroupOption).rate"
:description="(option as unknown as DefaultSubscriptionGroupOption).description"
:selected="selected"
/>
</template>
</Select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t('admin.settings.defaults.subscriptionValidityDays') }}
</label>
<input
v-model.number="item.validity_days"
type="number"
min="1"
max="36500"
class="input h-[42px]"
/>
</div>
<div class="flex items-end">
<button
type="button"
class="btn btn-secondary default-sub-delete-btn w-full text-red-600 hover:text-red-700 dark:text-red-400"
@click="removeDefaultSubscription(index)"
>
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1157,9 +1249,17 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
import type {
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting
} from '@/api/admin/settings'
import type { AdminGroup } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import Select from '@/components/common/Select.vue'
import GroupBadge from '@/components/common/GroupBadge.vue'
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
import Toggle from '@/components/common/Toggle.vue'
import { useClipboard } from '@/composables/useClipboard'
import { useAppStore } from '@/stores'
@@ -1181,6 +1281,7 @@ const adminApiKeyExists = ref(false)
const adminApiKeyMasked = ref('')
const adminApiKeyOperating = ref(false)
const newAdminApiKey = ref('')
const subscriptionGroups = ref<AdminGroup[]>([])
// Stream Timeout 状态
const streamTimeoutLoading = ref(true)
@@ -1193,6 +1294,16 @@ const streamTimeoutForm = reactive({
threshold_window_minutes: 10
})
interface DefaultSubscriptionGroupOption {
value: number
label: string
description: string | null
platform: AdminGroup['platform']
subscriptionType: AdminGroup['subscription_type']
rate: number
[key: string]: unknown
}
type SettingsForm = SystemSettings & {
smtp_password: string
turnstile_secret_key: string
@@ -1209,6 +1320,7 @@ const form = reactive<SettingsForm>({
totp_encryption_key_configured: false,
default_balance: 0,
default_concurrency: 1,
default_subscriptions: [],
site_name: 'Sub2API',
site_logo: '',
site_subtitle: 'Subscription to API Conversion Platform',
@@ -1257,6 +1369,17 @@ const form = reactive<SettingsForm>({
min_claude_code_version: ''
})
const defaultSubscriptionGroupOptions = computed<DefaultSubscriptionGroupOption[]>(() =>
subscriptionGroups.value.map((group) => ({
value: group.id,
label: group.name,
description: group.description,
platform: group.platform,
subscriptionType: group.subscription_type,
rate: group.rate_multiplier
}))
)
// LinuxDo OAuth redirect URL suggestion
const linuxdoRedirectUrlSuggestion = computed(() => {
if (typeof window === 'undefined') return ''
@@ -1316,6 +1439,14 @@ async function loadSettings() {
try {
const settings = await adminAPI.settings.getSettings()
Object.assign(form, settings)
form.default_subscriptions = Array.isArray(settings.default_subscriptions)
? settings.default_subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0)
.map((item) => ({
group_id: item.group_id,
validity_days: item.validity_days
}))
: []
form.smtp_password = ''
form.turnstile_secret_key = ''
form.linuxdo_connect_client_secret = ''
@@ -1328,9 +1459,60 @@ async function loadSettings() {
}
}
async function loadSubscriptionGroups() {
try {
const groups = await adminAPI.groups.getAll()
subscriptionGroups.value = groups.filter(
(group) => group.subscription_type === 'subscription' && group.status === 'active'
)
} catch (error) {
console.error('Failed to load subscription groups:', error)
subscriptionGroups.value = []
}
}
function addDefaultSubscription() {
if (subscriptionGroups.value.length === 0) return
const existing = new Set(form.default_subscriptions.map((item) => item.group_id))
const candidate = subscriptionGroups.value.find((group) => !existing.has(group.id))
if (!candidate) return
form.default_subscriptions.push({
group_id: candidate.id,
validity_days: 30
})
}
function removeDefaultSubscription(index: number) {
form.default_subscriptions.splice(index, 1)
}
async function saveSettings() {
saving.value = true
try {
const normalizedDefaultSubscriptions = form.default_subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0)
.map((item: DefaultSubscriptionSetting) => ({
group_id: item.group_id,
validity_days: Math.min(36500, Math.max(1, Math.floor(item.validity_days)))
}))
const seenGroupIDs = new Set<number>()
const duplicateDefaultSubscription = normalizedDefaultSubscriptions.find((item) => {
if (seenGroupIDs.has(item.group_id)) {
return true
}
seenGroupIDs.add(item.group_id)
return false
})
if (duplicateDefaultSubscription) {
appStore.showError(
t('admin.settings.defaults.defaultSubscriptionsDuplicate', {
groupId: duplicateDefaultSubscription.group_id
})
)
return
}
const payload: UpdateSettingsRequest = {
registration_enabled: form.registration_enabled,
email_verify_enabled: form.email_verify_enabled,
@@ -1340,6 +1522,7 @@ async function saveSettings() {
totp_enabled: form.totp_enabled,
default_balance: form.default_balance,
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
site_name: form.site_name,
site_logo: form.site_logo,
site_subtitle: form.site_subtitle,
@@ -1538,7 +1721,18 @@ async function saveStreamTimeoutSettings() {
onMounted(() => {
loadSettings()
loadSubscriptionGroups()
loadAdminApiKey()
loadStreamTimeoutSettings()
})
</script>
<style scoped>
.default-sub-group-select :deep(.select-trigger) {
@apply h-[42px];
}
.default-sub-delete-btn {
@apply h-[42px];
}
</style>