From 0464856c4aa5deb613dabff662f6ca6bf98fba13 Mon Sep 17 00:00:00 2001
From: Lyonle <214648221+lyon-le@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:27:15 +0800
Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Fast/Flex=20=E7=AD=96?=
=?UTF-8?q?=E7=95=A5=E6=94=AF=E6=8C=81=E6=90=9C=E7=B4=A2=E9=80=89=E6=8B=A9?=
=?UTF-8?q?=E7=94=A8=E6=88=B7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
将手工 User ID 输入替换为带防抖的邮箱模糊搜索和多选标签,同时继续保存 user_ids。
回显已有用户邮箱并保留无法解析的历史 ID,补齐中英文文案路径及组件回归测试。
---
.../__tests__/openaiFastPolicyLocales.spec.ts | 30 +++
.../src/i18n/locales/en/admin/settings.ts | 12 +-
.../src/i18n/locales/zh/admin/settings.ts | 12 +-
frontend/src/views/admin/SettingsView.vue | 71 +-----
.../settings/OpenAIFastPolicyUserSelector.vue | 229 ++++++++++++++++++
.../OpenAIFastPolicyUserSelector.spec.ts | 112 +++++++++
6 files changed, 390 insertions(+), 76 deletions(-)
create mode 100644 frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts
create mode 100644 frontend/src/views/admin/settings/OpenAIFastPolicyUserSelector.vue
create mode 100644 frontend/src/views/admin/settings/__tests__/OpenAIFastPolicyUserSelector.spec.ts
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 @@
+
+
+
+
+
+ {{ selectedUserLabel(userId) }}
+
+ #{{ userId }}
+
+ {{ t("admin.settings.openaiFastPolicy.userDeleted") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("common.loading") }}
+
+
+ {{ t("admin.settings.openaiFastPolicy.userSearchEmpty") }}
+
+
+
+
+
+
+
+
+
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([[[]]])
+ })
+})