diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue index 4d02c4d6e..45161be62 100644 --- a/frontend/src/components/admin/user/UserEditModal.vue +++ b/frontend/src/components/admin/user/UserEditModal.vue @@ -43,7 +43,16 @@
- + +

{{ t('admin.users.form.concurrencyHint') }}

@@ -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 diff --git a/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts new file mode 100644 index 000000000..5d04b55f5 --- /dev/null +++ b/frontend/src/components/admin/user/__tests__/UserEditModal.spec.ts @@ -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()), + useI18n: () => ({ + t: (key: string, params?: Record) => + 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: '
' + }, + 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() + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index a7521aebe..9b4040099 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 00ee1fbfc..c2e842e8b 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -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: '设置允许分组',