mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
feat: allow wallet fallback after plan quota exhaustion
This commit is contained in:
@@ -21,6 +21,7 @@ export interface UserPreferences {
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
language?: string
|
||||
notifications_enabled?: boolean
|
||||
allow_wallet_overage?: boolean
|
||||
[key: string]: unknown // 允许扩展其他偏好设置
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface UserPreferences {
|
||||
theme: string
|
||||
language: string
|
||||
timezone?: string
|
||||
allow_wallet_overage?: boolean
|
||||
notifications?: {
|
||||
email?: boolean
|
||||
usage_alerts?: boolean
|
||||
|
||||
@@ -1609,11 +1609,22 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
|
||||
'GET /api/users/me/preferences': async () => {
|
||||
await delay()
|
||||
return createMockResponse(getCurrentProfile().preferences || { theme: 'auto', language: 'zh-CN' })
|
||||
return createMockResponse({
|
||||
allow_wallet_overage: false,
|
||||
...(getCurrentProfile().preferences || { theme: 'auto', language: 'zh-CN' }),
|
||||
})
|
||||
},
|
||||
|
||||
'PUT /api/users/me/preferences': async () => {
|
||||
'PUT /api/users/me/preferences': async (config) => {
|
||||
await delay()
|
||||
const payload = mockRequestObject(config)
|
||||
if (typeof payload.allow_wallet_overage === 'boolean') {
|
||||
const profile = getCurrentProfile()
|
||||
profile.preferences = {
|
||||
...(profile.preferences || { theme: 'auto', language: 'zh-CN' }),
|
||||
allow_wallet_overage: payload.allow_wallet_overage,
|
||||
}
|
||||
}
|
||||
return createMockResponse({ message: '偏好设置已更新(演示模式)' })
|
||||
},
|
||||
|
||||
|
||||
@@ -746,15 +746,9 @@
|
||||
每日额度套餐本身已按类型互斥;填写后还可与其他权益类型的同名组整包互斥。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3">
|
||||
<div>
|
||||
<Label>允许超额扣钱包</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
额度不足时继续使用钱包余额
|
||||
</p>
|
||||
</div>
|
||||
<Switch v-model="form.allow_wallet_overage" />
|
||||
</div>
|
||||
<p class="rounded-xl border border-border/60 bg-card/50 p-3 text-xs leading-5 text-muted-foreground">
|
||||
套餐额度不足时是否使用钱包余额,由用户在个人设置中选择,默认关闭。
|
||||
</p>
|
||||
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3 opacity-70">
|
||||
<div>
|
||||
<Label>额度结转</Label>
|
||||
@@ -1272,7 +1266,6 @@ interface PlanFormState {
|
||||
daily_quota_usd: number
|
||||
reset_timezone: string
|
||||
carry_over: boolean
|
||||
allow_wallet_overage: boolean
|
||||
daily_quota_replacement_group: string
|
||||
membership_group_enabled: boolean
|
||||
grant_user_groups: string[]
|
||||
@@ -1526,11 +1519,7 @@ const dailyQuotaSummaryText = computed(() =>
|
||||
: '每天独立 USD 消费用量,默认不结转'
|
||||
)
|
||||
|
||||
const dailyQuotaDetailText = computed(() =>
|
||||
form.allow_wallet_overage
|
||||
? '每日额度不足时会继续使用钱包余额,适合希望用户不中断请求的套餐。'
|
||||
: '每日额度不足时不再继续扣钱包,适合严格封顶的月卡或体验卡。'
|
||||
)
|
||||
const dailyQuotaDetailText = '优先使用每日套餐额度;额度不足时是否使用钱包余额,由用户在个人设置中选择,默认关闭。'
|
||||
|
||||
const membershipSummaryText = computed(() =>
|
||||
planMode.value === 'mixed'
|
||||
@@ -1568,7 +1557,6 @@ function buildDefaultForm(): PlanFormState {
|
||||
daily_quota_usd: 50,
|
||||
reset_timezone: 'Asia/Shanghai',
|
||||
carry_over: false,
|
||||
allow_wallet_overage: false,
|
||||
daily_quota_replacement_group: '',
|
||||
membership_group_enabled: false,
|
||||
grant_user_groups: [],
|
||||
@@ -1732,7 +1720,6 @@ function formFromPlan(plan: BillingPlan): PlanFormState {
|
||||
next.daily_quota_usd = Number(quota.daily_quota_usd || next.daily_quota_usd)
|
||||
next.reset_timezone = quota.reset_timezone || 'Asia/Shanghai'
|
||||
next.carry_over = Boolean(quota.carry_over)
|
||||
next.allow_wallet_overage = Boolean(quota.allow_wallet_overage)
|
||||
next.daily_quota_replacement_group = quota.replacement_group || ''
|
||||
} else if (entitlement.type === 'membership_group') {
|
||||
const membership = entitlement as MembershipGroupEntitlement
|
||||
@@ -1813,7 +1800,6 @@ function buildEntitlements(): BillingEntitlement[] {
|
||||
daily_quota_usd: Number(form.daily_quota_usd),
|
||||
reset_timezone: form.reset_timezone.trim() || 'Asia/Shanghai',
|
||||
carry_over: false,
|
||||
allow_wallet_overage: Boolean(form.allow_wallet_overage),
|
||||
}
|
||||
attachReplacementGroup(entitlement, form.daily_quota_replacement_group)
|
||||
entitlements.push(entitlement)
|
||||
|
||||
@@ -503,6 +503,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-foreground">
|
||||
额度使用
|
||||
</h4>
|
||||
<div class="flex items-center justify-between gap-4 py-2">
|
||||
<div class="flex-1">
|
||||
<Label
|
||||
for="allow-wallet-overage"
|
||||
class="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
套餐额度不可用时使用余额
|
||||
</Label>
|
||||
<p
|
||||
id="allow-wallet-overage-description"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
默认关闭,套餐额度不足时不会自动扣除钱包余额。开启后优先使用套餐额度,额度耗尽、不可用或处于恢复期时使用钱包余额。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-wallet-overage"
|
||||
:model-value="allowWalletOverage"
|
||||
:disabled="!billingPreferencesLoaded || savingWalletOverage"
|
||||
aria-describedby="allow-wallet-overage-description"
|
||||
@update:model-value="updateWalletOverage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-medium text-foreground">
|
||||
通知设置
|
||||
@@ -744,6 +773,9 @@ const featureSettingsForm = ref({
|
||||
|
||||
const savingProfile = ref(false)
|
||||
const savingFeatureSettings = ref(false)
|
||||
const allowWalletOverage = ref(false)
|
||||
const billingPreferencesLoaded = ref(false)
|
||||
const savingWalletOverage = ref(false)
|
||||
const changingPassword = ref(false)
|
||||
const sessionsLoading = ref(false)
|
||||
const sessionActionLoading = ref<string | null>(null)
|
||||
@@ -1001,6 +1033,8 @@ async function handleUnbind(providerType: string) {
|
||||
async function loadPreferences() {
|
||||
try {
|
||||
const prefs = await meApi.getPreferences()
|
||||
allowWalletOverage.value = prefs.allow_wallet_overage === true
|
||||
billingPreferencesLoaded.value = true
|
||||
|
||||
// 主题以本地 localStorage 为准(useDarkMode 在应用启动时已初始化)
|
||||
// 这样可以避免刷新页面时主题被服务端旧值覆盖
|
||||
@@ -1169,6 +1203,23 @@ async function handleRevokeOtherSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWalletOverage(enabled: boolean) {
|
||||
if (!billingPreferencesLoaded.value || savingWalletOverage.value) return
|
||||
const previous = allowWalletOverage.value
|
||||
allowWalletOverage.value = enabled
|
||||
savingWalletOverage.value = true
|
||||
try {
|
||||
await meApi.updatePreferences({ allow_wallet_overage: enabled })
|
||||
success('设置已保存')
|
||||
} catch (error) {
|
||||
allowWalletOverage.value = previous
|
||||
log.error('更新额度使用设置失败:', error)
|
||||
showError('保存设置失败')
|
||||
} finally {
|
||||
savingWalletOverage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePreferences() {
|
||||
try {
|
||||
await meApi.updatePreferences({
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
|
||||
{{ dailyQuota?.allow_wallet_overage ? '已开启套餐不足时使用钱包余额' : '套餐额度不足时会拒绝请求,可在个人设置中开启使用余额' }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -227,3 +227,72 @@ describe('Settings language preferences', () => {
|
||||
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings wallet fallback preferences', () => {
|
||||
function walletSwitch(root: HTMLElement): HTMLButtonElement {
|
||||
const control = root.querySelector<HTMLButtonElement>('#allow-wallet-overage')
|
||||
if (!control) throw new Error('The wallet fallback switch was not rendered')
|
||||
return control
|
||||
}
|
||||
|
||||
it('defaults to off for existing users and waits for preferences before allowing changes', async () => {
|
||||
const preferences = deferred<ReturnType<typeof serverPreferences>>()
|
||||
meApiMock.getPreferences.mockReturnValueOnce(preferences.promise)
|
||||
const root = mountSettings()
|
||||
const control = walletSwitch(root)
|
||||
|
||||
expect(control.getAttribute('aria-checked')).toBe('false')
|
||||
expect(control.disabled).toBe(true)
|
||||
control.click()
|
||||
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
|
||||
|
||||
preferences.resolve(serverPreferences())
|
||||
await flushPromises()
|
||||
expect(control.disabled).toBe(false)
|
||||
expect(control.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('loads the saved choice and persists both disabling and enabling without changing other preferences', async () => {
|
||||
meApiMock.getPreferences.mockResolvedValue({ ...serverPreferences(), allow_wallet_overage: true })
|
||||
const root = mountSettings()
|
||||
await flushPromises()
|
||||
const control = walletSwitch(root)
|
||||
|
||||
expect(control.getAttribute('aria-checked')).toBe('true')
|
||||
control.click()
|
||||
await flushPromises()
|
||||
expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: false })
|
||||
expect(control.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
control.click()
|
||||
await flushPromises()
|
||||
expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: true })
|
||||
expect(control.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
chooseEnglish(root)
|
||||
await flushPromises()
|
||||
expect(meApiMock.updatePreferences.mock.lastCall?.[0]).not.toHaveProperty('allow_wallet_overage')
|
||||
})
|
||||
|
||||
it('prevents duplicate saves and restores the saved choice when saving fails', async () => {
|
||||
let reject!: (reason: Error) => void
|
||||
meApiMock.updatePreferences.mockReturnValueOnce(new Promise<void>((_, fail) => { reject = fail }))
|
||||
const root = mountSettings()
|
||||
await flushPromises()
|
||||
const control = walletSwitch(root)
|
||||
|
||||
control.click()
|
||||
await nextTick()
|
||||
expect(control.disabled).toBe(true)
|
||||
expect(control.getAttribute('aria-checked')).toBe('true')
|
||||
control.click()
|
||||
expect(meApiMock.updatePreferences).toHaveBeenCalledTimes(1)
|
||||
|
||||
reject(new Error('Save failed'))
|
||||
await flushPromises()
|
||||
expect(control.disabled).toBe(false)
|
||||
expect(control.getAttribute('aria-checked')).toBe('false')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('保存设置失败')
|
||||
expect(toastMock.success).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user