feat(frontend): Fast/Flex 策略支持搜索选择用户

将手工 User ID 输入替换为带防抖的邮箱模糊搜索和多选标签,同时继续保存 user_ids。

回显已有用户邮箱并保留无法解析的历史 ID,补齐中英文文案路径及组件回归测试。
This commit is contained in:
Lyonle
2026-07-10 22:27:15 +08:00
parent e316ebf528
commit 0464856c4a
6 changed files with 390 additions and 76 deletions
@@ -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'
})
})
})
@@ -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.',
@@ -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: '留空则使用默认错误消息。',
+5 -66
View File
@@ -1199,60 +1199,10 @@
<p class="mb-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.openaiFastPolicy.userIdsHint") }}
</p>
<div
v-for="(_, userIDIndex) in rule.user_ids || []"
:key="userIDIndex"
class="mb-1.5 flex items-center gap-2"
>
<input
v-model.number="rule.user_ids![userIDIndex]"
type="number"
min="1"
step="1"
class="input input-sm flex-1"
:placeholder="t('admin.settings.openaiFastPolicy.userIdPlaceholder')"
/>
<button
type="button"
@click="removeOpenAIFastPolicyUserID(rule, userIDIndex)"
class="shrink-0 rounded p-1 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
:title="t('admin.settings.openaiFastPolicy.removeUserId')"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
type="button"
@click="addOpenAIFastPolicyUserID(rule)"
class="mb-2 inline-flex items-center gap-1 text-xs text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t("admin.settings.openaiFastPolicy.addUserId") }}
</button>
<OpenAIFastPolicyUserSelector
:model-value="rule.user_ids || []"
@update:model-value="rule.user_ids = $event"
/>
</div>
<!-- Error Message (only when action=block) -->
@@ -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("");
@@ -0,0 +1,229 @@
<template>
<div ref="containerRef" class="relative">
<div v-if="selectedUserIds.length > 0" class="mb-2 flex flex-wrap gap-2">
<span
v-for="userId in selectedUserIds"
:key="userId"
class="inline-flex max-w-full items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs text-gray-700 dark:bg-dark-600 dark:text-gray-200"
>
<span class="max-w-64 truncate font-medium" :title="selectedUserLabel(userId)">
{{ selectedUserLabel(userId) }}
</span>
<span class="shrink-0 text-gray-400">#{{ userId }}</span>
<span
v-if="selectedUsers[userId]?.deleted"
class="shrink-0 text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
</span>
<button
type="button"
class="shrink-0 rounded text-gray-400 hover:text-red-600 dark:hover:text-red-400"
:aria-label="t('admin.settings.openaiFastPolicy.removeUser')"
:title="t('admin.settings.openaiFastPolicy.removeUser')"
@click="removeUser(userId)"
>
<Icon name="x" size="xs" :stroke-width="2" />
</button>
</span>
</div>
<div class="relative">
<Icon
name="search"
size="sm"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
/>
<input
v-model="searchQuery"
type="text"
autocomplete="off"
class="input input-sm w-full pl-9"
:placeholder="t('admin.settings.openaiFastPolicy.userSearchPlaceholder')"
@input="debounceSearch"
@focus="showDropdown = true"
/>
</div>
<div
v-if="showDropdown && searchQuery.trim()"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-700"
>
<div v-if="searchLoading" class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{{ t("common.loading") }}
</div>
<div
v-else-if="availableResults.length === 0"
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
>
{{ t("admin.settings.openaiFastPolicy.userSearchEmpty") }}
</div>
<template v-else>
<button
v-for="user in availableResults"
:key="user.id"
type="button"
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-600"
@click="selectUser(user)"
>
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white">
{{ user.email }}
<span v-if="user.deleted" class="ml-1 text-xs font-normal text-gray-400">
{{ t("admin.settings.openaiFastPolicy.userDeleted") }}
</span>
</span>
<span class="shrink-0 text-xs text-gray-400">#{{ user.id }}</span>
</button>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { adminAPI } from "@/api/admin";
import type { SimpleUser } from "@/api/admin/usage";
import Icon from "@/components/icons/Icon.vue";
const props = defineProps<{
modelValue: number[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: number[]];
}>();
const { t } = useI18n();
const containerRef = ref<HTMLElement | null>(null);
const searchQuery = ref("");
const searchResults = ref<SimpleUser[]>([]);
const searchLoading = ref(false);
const showDropdown = ref(false);
const selectedUsers = ref<Record<number, SimpleUser>>({});
let searchTimer: ReturnType<typeof setTimeout> | null = null;
let searchSequence = 0;
const selectedUserIds = computed(() =>
Array.from(new Set(props.modelValue.filter((id) => Number.isInteger(id) && id > 0))),
);
const availableResults = computed(() => {
const selected = new Set(selectedUserIds.value);
return searchResults.value
.filter((user) => !selected.has(user.id))
.sort((a, b) => Number(a.deleted) - Number(b.deleted));
});
function selectedUserLabel(userId: number): string {
return selectedUsers.value[userId]?.email ||
t("admin.settings.openaiFastPolicy.userIdFallback", { id: userId });
}
function clearPendingSearch(): void {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchSequence += 1;
}
function debounceSearch(): void {
clearPendingSearch();
const query = searchQuery.value.trim();
showDropdown.value = true;
if (!query) {
searchResults.value = [];
searchLoading.value = false;
return;
}
const sequence = searchSequence;
searchTimer = setTimeout(async () => {
searchLoading.value = true;
try {
const results = await adminAPI.usage.searchUsers(query);
if (sequence === searchSequence) {
searchResults.value = results;
}
} catch {
if (sequence === searchSequence) {
searchResults.value = [];
}
} finally {
if (sequence === searchSequence) {
searchLoading.value = false;
}
}
}, 300);
}
function selectUser(user: SimpleUser): void {
selectedUsers.value = { ...selectedUsers.value, [user.id]: user };
emit("update:modelValue", [...selectedUserIds.value, user.id]);
clearPendingSearch();
searchQuery.value = "";
searchResults.value = [];
searchLoading.value = false;
showDropdown.value = false;
}
function removeUser(userId: number): void {
emit(
"update:modelValue",
selectedUserIds.value.filter((id) => id !== userId),
);
}
async function hydrateSelectedUsers(userIds: number[]): Promise<void> {
const missing = userIds.filter((id) => !selectedUsers.value[id]);
if (missing.length === 0) return;
const users = await Promise.all(
missing.map(async (id) => {
try {
const user = await adminAPI.users.getById(id, true);
return {
id: user.id,
email: user.email,
deleted: Boolean(user.deleted_at),
} satisfies SimpleUser;
} catch {
return null;
}
}),
);
const next = { ...selectedUsers.value };
for (const user of users) {
if (user && props.modelValue.includes(user.id)) {
next[user.id] = user;
}
}
selectedUsers.value = next;
}
function handleDocumentClick(event: MouseEvent): void {
const target = event.target as Node | null;
if (target && !containerRef.value?.contains(target)) {
showDropdown.value = false;
}
}
watch(
selectedUserIds,
(userIds) => {
void hydrateSelectedUsers(userIds);
},
{ immediate: true },
);
onMounted(() => {
document.addEventListener("click", handleDocumentClick);
});
onUnmounted(() => {
clearPendingSearch();
document.removeEventListener("click", handleDocumentClick);
});
</script>
@@ -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<string, string> = {
'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<string, unknown>) => {
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([[[]]])
})
})