fix(users): wait for group configuration before enabling save

This commit is contained in:
chihao.ou
2026-09-19 19:42:12 +08:00
parent 1a9d49e16f
commit cb2bb6084c
2 changed files with 76 additions and 2 deletions
@@ -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)
})
})