mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 11:33:18 +08:00
fix(frontend): accept unlimited (0) user concurrency in the edit dialog
The admin user edit modal rejected concurrency < 1, so a user whose concurrency is already 0 could not be saved at all — the guard runs before the request, blocking notes, password, role and RPM edits on that user too. Everywhere else already treats 0 as unlimited: the gateway skips slot limiting when maxConcurrency <= 0 (ConcurrencyService.AcquireUserSlot), the batch limits endpoint binds concurrency with min=0, and the bulk edit modal only rejects negative values. Reject negative and non-integer values instead, mirror the RPM field with min/step and a "0 = unlimited" placeholder and hint, and rename the error key to match its new meaning. Account concurrency is unchanged.
This commit is contained in:
@@ -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: '设置允许分组',
|
||||
|
||||
Reference in New Issue
Block a user