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