diff --git a/frontend/src/components/admin/user/UserEditModal.vue b/frontend/src/components/admin/user/UserEditModal.vue
index 4d02c4d6e0..45161be629 100644
--- a/frontend/src/components/admin/user/UserEditModal.vue
+++ b/frontend/src/components/admin/user/UserEditModal.vue
@@ -43,7 +43,16 @@
@@ -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 0000000000..5d04b55f59
--- /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 a7521aebed..9b4040099d 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 00ee1fbfc1..c2e842e8b0 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: '设置允许分组',