mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
fix(users): wait for group configuration before enabling save
This commit is contained in:
@@ -188,7 +188,7 @@
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSave" :disabled="submitting" class="btn btn-primary px-6">
|
||||
<button @click="handleSave" :disabled="submitting || !loaded" class="btn btn-primary px-6">
|
||||
<svg v-if="submitting" class="-ml-1 mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
@@ -228,6 +228,7 @@ const groups = ref<Group[]>([])
|
||||
const groupConfigs = ref<GroupRateConfig[]>([])
|
||||
const originalGroupRates = ref<Record<number, number>>({}) // 记录原始专属倍率,用于检测删除
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const submitting = ref(false)
|
||||
const restrictPublicGroups = ref(false)
|
||||
|
||||
@@ -248,6 +249,7 @@ watch(
|
||||
)
|
||||
|
||||
const load = async () => {
|
||||
loaded.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminAPI.groups.list(1, 1000)
|
||||
@@ -274,6 +276,7 @@ const load = async () => {
|
||||
isSelected:
|
||||
g.is_exclusive || restrictPublicGroups.value ? userAllowedGroups.includes(g.id) : true,
|
||||
}))
|
||||
loaded.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load groups:', error)
|
||||
} finally {
|
||||
@@ -318,7 +321,7 @@ const updateCustomRate = (groupId: number, value: string) => {
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!props.user) return
|
||||
if (!props.user || !loaded.value) return
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { AdminUser } from '@/types'
|
||||
import UserAllowedGroupsModal from '../UserAllowedGroupsModal.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ list: vi.fn(), update: vi.fn() }))
|
||||
vi.mock('@/api/admin', () => ({ adminAPI: { groups: { list: mocks.list }, users: { update: mocks.update } } }))
|
||||
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess: vi.fn(), showError: vi.fn() }) }))
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
const response = { items: [{ id: 7, name: 'Exclusive', platform: 'openai', is_exclusive: true, subscription_type: 'standard', status: 'active', rate_multiplier: 1 }] }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mocks.list.mockResolvedValue(response)
|
||||
mocks.update.mockResolvedValue(undefined)
|
||||
})
|
||||
async function openDialog() {
|
||||
const wrapper = mount(UserAllowedGroupsModal, {
|
||||
props: { show: false, user: { id: 1, email: 'user@example.com', allowed_groups: [7], group_rates: { 7: 0.5 } } as AdminUser },
|
||||
global: { stubs: { BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' }, PlatformIcon: true } }
|
||||
})
|
||||
await wrapper.setProps({ show: true })
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('UserAllowedGroupsModal load readiness', () => {
|
||||
it('cannot save an empty configuration while groups are loading', async () => {
|
||||
let resolve!: (value: typeof response) => void
|
||||
mocks.list.mockReturnValueOnce(new Promise(res => { resolve = res }))
|
||||
const wrapper = await openDialog()
|
||||
const save = wrapper.get('button.btn-primary')
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
await save.trigger('click')
|
||||
expect(mocks.update).not.toHaveBeenCalled()
|
||||
resolve(response)
|
||||
await flushPromises()
|
||||
expect(save.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('cannot save after loading fails', async () => {
|
||||
mocks.list.mockRejectedValueOnce(new Error('Offline'))
|
||||
const wrapper = await openDialog()
|
||||
await flushPromises()
|
||||
const save = wrapper.get('button.btn-primary')
|
||||
expect(save.attributes('disabled')).toBeDefined()
|
||||
await save.trigger('click')
|
||||
expect(mocks.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reuse a previous successful load after reopening fails', async () => {
|
||||
const wrapper = await openDialog()
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ show: false })
|
||||
mocks.list.mockRejectedValueOnce(new Error('Offline'))
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('button.btn-primary').attributes('disabled')).toBeDefined()
|
||||
expect(mocks.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves existing grants and rates on a successful save', async () => {
|
||||
const wrapper = await openDialog()
|
||||
await flushPromises()
|
||||
await wrapper.get('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.update).toHaveBeenCalledWith(1, { allowed_groups: [7], restrict_public_groups: false, group_rates: { 7: 0.5 } })
|
||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user