mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3997 from lyon-le/feat/openai-fast-policy-user-scope
feat(frontend): Fast/Flex 策略支持搜索选择用户
This commit is contained in:
@@ -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: '留空则使用默认错误消息。',
|
||||
|
||||
@@ -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([[[]]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user