Merge pull request #6075 from YogaSakti/fix/user-edit-allow-zero-concurrency

fix(frontend): accept unlimited (0) user concurrency in the edit dialog
This commit is contained in:
Wesley Liddick
2026-08-24 11:20:09 +08:00
committed by GitHub
4 changed files with 109 additions and 5 deletions
@@ -43,7 +43,16 @@
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
<input
v-model.number="form.concurrency"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.users.form.concurrencyPlaceholder')"
data-test="concurrency-input"
/>
<p class="input-hint">{{ t('admin.users.form.concurrencyHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
@@ -132,8 +141,9 @@ const handleUpdateUser = async () => {
appStore.showError(t('admin.users.emailRequired'))
return
}
if (form.concurrency < 1) {
appStore.showError(t('admin.users.concurrencyMin'))
// 0 = 不限制,与网关 (AcquireUserSlot: maxConcurrency <= 0) 和批量改限额一致
if (!Number.isInteger(form.concurrency) || form.concurrency < 0) {
appStore.showError(t('admin.users.concurrencyNonNegative'))
return
}
const userId = props.user.id
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import UserEditModal from '../UserEditModal.vue'
const { update, updateUserAttributeValues, showSuccess, showError } = vi.hoisted(() => ({
update: vi.fn(),
updateUserAttributeValues: vi.fn(),
showSuccess: vi.fn(),
showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
users: { update },
userAttributes: { updateUserAttributeValues }
}
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess, showError })
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard: vi.fn() })
}))
// useStepUp pulls in the API client, which needs the real i18n instance.
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-i18n')>()),
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key
})
}))
const mountModal = (concurrency: number) => mount(UserEditModal, {
props: {
show: true,
user: { id: 7, email: 'user@example.test', username: 'user', notes: '', role: 'user', concurrency, rpm_limit: 0 } as never
},
global: {
stubs: {
BaseDialog: {
props: ['show', 'title'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>'
},
Select: true,
Icon: true,
UserAttributeForm: true,
TotpStepUpDialog: true
}
}
})
describe('UserEditModal concurrency', () => {
beforeEach(() => {
update.mockReset()
updateUserAttributeValues.mockReset()
showSuccess.mockReset()
showError.mockReset()
update.mockResolvedValue({})
})
// Regression coverage for issue #5977: the gateway treats concurrency <= 0 as
// unlimited (AcquireUserSlot) and both the batch limits endpoint and the bulk
// edit modal accept 0, so this dialog must not be the only place that rejects
// it — doing so blocked every other edit on such a user.
it('saves an unlimited (0) concurrency instead of blocking the whole form', async () => {
const wrapper = mountModal(0)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(showError).not.toHaveBeenCalled()
expect(update).toHaveBeenCalledWith(7, expect.objectContaining({ concurrency: 0 }))
expect(wrapper.emitted('success')).toBeTruthy()
})
it('still rejects a negative concurrency', async () => {
const wrapper = mountModal(3)
await wrapper.get('[data-test="concurrency-input"]').setValue('-1')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(showError).toHaveBeenCalledWith('admin.users.concurrencyNonNegative')
expect(update).not.toHaveBeenCalled()
})
})
@@ -513,6 +513,8 @@ export default {
statusLabel: 'Status',
selectStatus: 'Select status',
rpmLimit: 'Requests Per Minute (RPM)',
concurrencyPlaceholder: '0 = unlimited',
concurrencyHint: 'Max concurrent requests for this user; 0 = unlimited.',
rpmLimitPlaceholder: '0 = unlimited',
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'
},
@@ -577,7 +579,7 @@ export default {
failedToToggle: 'Failed to update user status',
failedToLoadApiKeys: 'Failed to load user API keys',
emailRequired: 'Please enter email',
concurrencyMin: 'Concurrency must be at least 1',
concurrencyNonNegative: 'Concurrency cannot be negative; 0 = unlimited',
amountRequired: 'Please enter a valid amount',
insufficientBalance: 'Insufficient balance',
adjustBalance: 'Adjust Balance',
@@ -578,6 +578,8 @@ export default {
statusLabel: '状态',
selectStatus: '选择状态',
rpmLimit: '每分钟请求数 (RPM)',
concurrencyPlaceholder: '0 表示不限制',
concurrencyHint: '该用户的最大并发请求数,0 = 不限制',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效'
},
@@ -598,7 +600,7 @@ export default {
failedToSave: '保存用户失败',
failedToAdjust: '调整失败',
emailRequired: '请输入邮箱',
concurrencyMin: '并发数不能小于1',
concurrencyNonNegative: '并发数不能为负数,0 表示不限制',
amountRequired: '请输入有效金额',
insufficientBalance: '余额不足',
setAllowedGroups: '设置允许分组',