diff --git a/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts new file mode 100644 index 0000000000..7fd550c1ad --- /dev/null +++ b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import en from '../locales/en' +import zh from '../locales/zh' + +describe('OpenAI Fast/Flex policy locale keys', () => { + it('exposes user scope copy at the runtime zh path', () => { + expect(zh.admin.settings.openaiFastPolicy).toMatchObject({ + userIds: '指定用户', + userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。', + userSearchPlaceholder: '输入用户邮箱搜索', + userSearchEmpty: '未找到匹配用户', + userDeleted: '(已删除)', + userIdFallback: '用户 #{id}', + removeUser: '移除用户' + }) + }) + + it('exposes user scope copy at the runtime en path', () => { + expect(en.admin.settings.openaiFastPolicy).toMatchObject({ + userIds: 'Specific users', + userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.', + userSearchPlaceholder: 'Search by user email', + userSearchEmpty: 'No matching users found', + userDeleted: '(deleted)', + userIdFallback: 'User #{id}', + removeUser: 'Remove user' + }) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 37dc8aa1c8..e08be9f591 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -979,11 +979,6 @@ export default { scopeOAuth: 'OAuth only', scopeAPIKey: 'API Key only', scopeBedrock: 'Bedrock only', - userIds: 'Specific user IDs', - userIdsHint: 'Leave empty to apply to all Sub2API users. Specified users match requests from their API keys and take precedence over global rules.', - userIdPlaceholder: 'e.g., 1001', - addUserId: 'Add user ID', - removeUserId: 'Remove user ID', errorMessage: 'Error message', errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for default message', @@ -1024,6 +1019,13 @@ export default { scopeOAuth: 'OAuth only', scopeAPIKey: 'API Key only', scopeBedrock: 'Bedrock only', + userIds: 'Specific users', + userIdsHint: 'Type any part of a user email to search. Leave empty to apply to all Sub2API users. Selected users match requests from their API keys and take precedence over global rules.', + userSearchPlaceholder: 'Search by user email', + userSearchEmpty: 'No matching users found', + userDeleted: '(deleted)', + userIdFallback: 'User #{id}', + removeUser: 'Remove user', errorMessage: 'Error message', errorMessagePlaceholder: 'Custom error message when blocked', errorMessageHint: 'Leave empty for the default message.', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 5c0d874b57..08c0dbcd53 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -974,11 +974,6 @@ export default { scopeOAuth: '仅 OAuth 账号', scopeAPIKey: '仅 API Key 账号', scopeBedrock: '仅 Bedrock 账号', - userIds: '指定用户 ID', - userIdsHint: '留空表示对全部 Sub2API 用户生效。指定后仅匹配这些用户的 API Key 请求,且优先于全局规则。', - userIdPlaceholder: '例如: 1001', - addUserId: '添加用户 ID', - removeUserId: '移除用户 ID', errorMessage: '错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息', @@ -1019,6 +1014,13 @@ export default { scopeOAuth: '仅 OAuth 账号', scopeAPIKey: '仅 API Key 账号', scopeBedrock: '仅 Bedrock 账号', + userIds: '指定用户', + userIdsHint: '输入任意邮箱关键词进行模糊搜索。留空表示对全部 Sub2API 用户生效;选中用户的 API Key 请求优先匹配用户规则。', + userSearchPlaceholder: '输入用户邮箱搜索', + userSearchEmpty: '未找到匹配用户', + userDeleted: '(已删除)', + userIdFallback: '用户 #{id}', + removeUser: '移除用户', errorMessage: '错误消息', errorMessagePlaceholder: '拦截时返回的自定义错误消息', errorMessageHint: '留空则使用默认错误消息。', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index a87cea9060..26253c7eb8 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1199,60 +1199,10 @@

{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}

-
- - -
- + @@ -7431,6 +7381,7 @@ import ProxySelector from "@/components/common/ProxySelector.vue"; import ImageUpload from "@/components/common/ImageUpload.vue"; import BackupSettings from "@/views/admin/BackupView.vue"; import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue"; +import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue"; import { useClipboard } from "@/composables/useClipboard"; import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates"; import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError"; @@ -10226,18 +10177,6 @@ function removeOpenAIFastPolicyRule(index: number) { openaiFastPolicyForm.rules.splice(index, 1); } -function addOpenAIFastPolicyUserID(rule: OpenAIFastPolicyRule) { - if (!rule.user_ids) rule.user_ids = []; - rule.user_ids.push(0); -} - -function removeOpenAIFastPolicyUserID( - rule: OpenAIFastPolicyRule, - idx: number, -) { - rule.user_ids?.splice(idx, 1); -} - function addOpenAIFastPolicyModelPattern(rule: OpenAIFastPolicyRule) { if (!rule.model_whitelist) rule.model_whitelist = []; rule.model_whitelist.push(""); diff --git a/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue b/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue new file mode 100644 index 0000000000..0b25c876ad --- /dev/null +++ b/frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue @@ -0,0 +1,229 @@ + + + diff --git a/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts b/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts new file mode 100644 index 0000000000..c8e1f0e1cf --- /dev/null +++ b/frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts @@ -0,0 +1,112 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import OpenAIFastPolicyUserSelector from '../OpenAIFastPolicyUserSelector.vue' + +const messages: Record = { + 'admin.settings.openaiFastPolicy.userDeleted': '(deleted)', + 'admin.settings.openaiFastPolicy.userIdFallback': 'User #{id}', + 'admin.settings.openaiFastPolicy.removeUser': 'Remove user', + 'admin.settings.openaiFastPolicy.userSearchPlaceholder': 'Search users', + 'admin.settings.openaiFastPolicy.userSearchEmpty': 'No users found', + 'common.loading': 'Loading', +} + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => { + const message = messages[key] ?? key + return params + ? Object.entries(params).reduce( + (value, [name, replacement]) => value.replace(`{${name}}`, String(replacement)), + message, + ) + : message + }, + }), +})) + +const mockSearchUsers = vi.fn() +const mockGetUserById = vi.fn() + +vi.mock('@/api/admin', () => ({ + adminAPI: { + usage: { + searchUsers: (...args: unknown[]) => mockSearchUsers(...args), + }, + users: { + getById: (...args: unknown[]) => mockGetUserById(...args), + }, + }, +})) + +describe('OpenAIFastPolicyUserSelector', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSearchUsers.mockReset() + mockGetUserById.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('hydrates existing IDs to email labels without changing the saved IDs', async () => { + mockGetUserById.mockResolvedValue({ + id: 7, + email: 'existing@example.com', + deleted_at: null, + }) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [7] }, + global: { stubs: { Icon: true } }, + }) + await flushPromises() + + expect(mockGetUserById).toHaveBeenCalledWith(7, true) + expect(wrapper.text()).toContain('existing@example.com') + expect(wrapper.text()).toContain('#7') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('searches after one character and adds the selected user ID', async () => { + mockSearchUsers.mockResolvedValue([ + { id: 9, email: 'alice@example.com', deleted: false }, + ]) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [] }, + global: { stubs: { Icon: true } }, + }) + const input = wrapper.get('input') + await input.trigger('focus') + await input.setValue('a') + await input.trigger('input') + vi.advanceTimersByTime(300) + await flushPromises() + + expect(mockSearchUsers).toHaveBeenCalledWith('a') + const result = wrapper.findAll('button').find((button) => + button.text().includes('alice@example.com'), + ) + expect(result).toBeDefined() + await result!.trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([[[9]]]) + }) + + it('keeps an unresolved saved ID visible and removable', async () => { + mockGetUserById.mockRejectedValue(new Error('not found')) + + const wrapper = mount(OpenAIFastPolicyUserSelector, { + props: { modelValue: [42] }, + global: { stubs: { Icon: true } }, + }) + await flushPromises() + + expect(wrapper.text()).toContain('User #42') + await wrapper.get('button[aria-label="Remove user"]').trigger('click') + expect(wrapper.emitted('update:modelValue')).toEqual([[[]]]) + }) +})