feat(rpm): RPM 限流模块优化
P0: - rpm_override 嵌入 Auth Cache Snapshot,消除每请求 DB 查询 (snapshot v6→v7) - 429 RPM 响应返回 Retry-After 头(当前分钟剩余秒数) P1: - ClearAll 按钮直连 DELETE API,带 loading 防重复 - 新增 GET /admin/users/:id/rpm-status 管理员 RPM 用量查询端点 优化: - checkRPM 从级联互斥改为并行取最严,user.rpm_limit 作为全局硬上限始终生效 - Override/Group 变更后自动失效 auth cache - fail-open 语义不变,Redis 故障不阻塞业务
This commit is contained in:
@@ -164,7 +164,8 @@ export interface GroupRateMultiplierEntry {
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rate_multiplier: number
|
||||
rate_multiplier?: number | null
|
||||
rpm_override?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -205,9 +206,7 @@ export async function clearGroupRateMultipliers(id: number): Promise<{ message:
|
||||
|
||||
/**
|
||||
* Batch set rate multipliers for users in a group
|
||||
* @param id - Group ID
|
||||
* @param entries - Array of { user_id, rate_multiplier }
|
||||
* @returns Success confirmation
|
||||
* Only touches rate_multiplier column; preserves rpm_override on existing rows.
|
||||
*/
|
||||
export async function batchSetGroupRateMultipliers(
|
||||
id: number,
|
||||
@@ -220,6 +219,60 @@ export async function batchSetGroupRateMultipliers(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* RPM override entry for a user in a group
|
||||
*/
|
||||
export interface GroupRPMOverrideEntry {
|
||||
user_id: number
|
||||
user_name: string
|
||||
user_email: string
|
||||
user_notes: string
|
||||
user_status: string
|
||||
rpm_override: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RPM overrides for users in a group (subset of rate-multipliers endpoint).
|
||||
*/
|
||||
export async function getGroupRPMOverrides(id: number): Promise<GroupRPMOverrideEntry[]> {
|
||||
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
|
||||
`/admin/groups/${id}/rate-multipliers`
|
||||
)
|
||||
return data
|
||||
.filter(e => e.rpm_override != null)
|
||||
.map(e => ({
|
||||
user_id: e.user_id,
|
||||
user_name: e.user_name,
|
||||
user_email: e.user_email,
|
||||
user_notes: e.user_notes,
|
||||
user_status: e.user_status,
|
||||
rpm_override: e.rpm_override as number
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set RPM overrides for users in a group.
|
||||
* Only touches rpm_override column; preserves rate_multiplier on existing rows.
|
||||
*/
|
||||
export async function batchSetGroupRPMOverrides(
|
||||
id: number,
|
||||
entries: Array<{ user_id: number; rpm_override: number }>
|
||||
): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.put<{ message: string }>(
|
||||
`/admin/groups/${id}/rpm-overrides`,
|
||||
{ entries }
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all RPM overrides for a group (preserves rate_multiplier).
|
||||
*/
|
||||
export async function clearGroupRPMOverrides(id: number): Promise<{ message: string }> {
|
||||
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rpm-overrides`)
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage summary (today + cumulative cost) for all groups
|
||||
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
|
||||
@@ -262,6 +315,9 @@ export const groupsAPI = {
|
||||
getGroupRateMultipliers,
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
getGroupRPMOverrides,
|
||||
clearGroupRPMOverrides,
|
||||
batchSetGroupRPMOverrides,
|
||||
updateSortOrder,
|
||||
getUsageSummary,
|
||||
getCapacitySummary
|
||||
|
||||
@@ -309,6 +309,7 @@ export interface SystemSettings {
|
||||
// Default settings
|
||||
default_balance: number;
|
||||
default_concurrency: number;
|
||||
default_user_rpm_limit: number;
|
||||
default_subscriptions: DefaultSubscriptionSetting[];
|
||||
auth_source_default_email_balance?: number;
|
||||
auth_source_default_email_concurrency?: number;
|
||||
@@ -482,6 +483,7 @@ export interface UpdateSettingsRequest {
|
||||
totp_enabled?: boolean; // TOTP 双因素认证
|
||||
default_balance?: number;
|
||||
default_concurrency?: number;
|
||||
default_user_rpm_limit?: number;
|
||||
default_subscriptions?: DefaultSubscriptionSetting[];
|
||||
auth_source_default_email_balance?: number;
|
||||
auth_source_default_email_concurrency?: number;
|
||||
|
||||
434
frontend/src/components/admin/group/GroupRPMOverridesModal.vue
Normal file
434
frontend/src/components/admin/group/GroupRPMOverridesModal.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.groups.rpmOverridesTitle')" width="wide" @close="handleClose">
|
||||
<div v-if="group" class="space-y-4">
|
||||
<!-- 分组信息 -->
|
||||
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700">
|
||||
<span class="inline-flex items-center gap-1.5" :class="platformColorClass">
|
||||
<PlatformIcon :platform="group.platform" size="sm" />
|
||||
{{ t('admin.groups.platforms.' + group.platform) }}
|
||||
</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
|
||||
<span class="text-gray-400">|</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.groups.groupRpmDefault') }}: {{ group.rpm_limit || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作区:添加用户 -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.addUserRpm') }}
|
||||
</h4>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.groups.searchUserPlaceholder')"
|
||||
@input="handleSearchUsers"
|
||||
@focus="showDropdown = true"
|
||||
/>
|
||||
<div
|
||||
v-if="showDropdown && searchResults.length > 0"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
|
||||
>
|
||||
<button
|
||||
v-for="user in searchResults"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
|
||||
@click="selectUser(user)"
|
||||
>
|
||||
<span class="text-gray-400">#{{ user.id }}</span>
|
||||
<span class="text-gray-900 dark:text-white">{{ user.username || user.email }}</span>
|
||||
<span v-if="user.username" class="text-xs text-gray-400">{{ user.email }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<input
|
||||
v-model.number="newRpm"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
class="hide-spinner input w-full"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary shrink-0"
|
||||
:disabled="!selectedUser || newRpm == null || newRpm < 0"
|
||||
@click="handleAddLocal"
|
||||
>
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="localEntries.length > 0" class="mt-3 flex items-center justify-end border-t border-gray-100 pt-3 dark:border-dark-600">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="clearing"
|
||||
class="rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
|
||||
@click="clearAllLocal"
|
||||
>
|
||||
<Icon v-if="clearing" name="refresh" size="sm" class="mr-1 inline animate-spin" />
|
||||
{{ t('admin.groups.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex justify-center py-6">
|
||||
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div v-else>
|
||||
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.groups.rpmOverrides') }} ({{ localEntries.length }})
|
||||
</h4>
|
||||
|
||||
<div v-if="localEntries.length === 0" class="py-6 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
{{ t('admin.groups.noRpmOverrides') }}
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<div class="max-h-[420px] overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 z-[1]">
|
||||
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">ID</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userName') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userNotes') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userStatus') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400" :title="t('admin.groups.columns.rpmOverrideHint')">{{ t('admin.groups.columns.rpmOverride') }}</th>
|
||||
<th class="w-10 px-2 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-600">
|
||||
<tr
|
||||
v-for="entry in paginatedLocalEntries"
|
||||
:key="entry.user_id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-dark-700/50"
|
||||
>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ entry.user_email }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500">{{ entry.user_id }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ entry.user_name || '-' }}</td>
|
||||
<td class="max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400" :title="entry.user_notes">{{ entry.user_notes || '-' }}</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
entry.user_status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ entry.user_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
autocomplete="off"
|
||||
:value="entry.rpm_override"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
@change="updateLocalRpm(entry.user_id, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@click="removeLocal(entry.user_id)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total="localEntries.length"
|
||||
:page="currentPage"
|
||||
:page-size="pageSize"
|
||||
@update:page="currentPage = $event"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="flex items-center gap-3 border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<template v-if="isDirty">
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">{{ t('admin.groups.unsavedChanges') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ t('admin.groups.revertChanges') }}
|
||||
</button>
|
||||
</template>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<button type="button" class="btn btn-sm px-4 py-1.5" @click="handleClose">
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isDirty"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm px-4 py-1.5"
|
||||
:disabled="saving"
|
||||
@click="handleSave"
|
||||
>
|
||||
<Icon v-if="saving" name="refresh" size="sm" class="mr-1 animate-spin" />
|
||||
{{ t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { GroupRPMOverrideEntry } from '@/api/admin/groups'
|
||||
import type { AdminGroup, AdminUser } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
|
||||
interface LocalEntry extends GroupRPMOverrideEntry {}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
group: AdminGroup | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const serverEntries = ref<GroupRPMOverrideEntry[]>([])
|
||||
const localEntries = ref<LocalEntry[]>([])
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<AdminUser[]>([])
|
||||
const showDropdown = ref(false)
|
||||
const selectedUser = ref<AdminUser | null>(null)
|
||||
const newRpm = ref<number | null>(null)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
const platformColorClass = computed(() => {
|
||||
switch (props.group?.platform) {
|
||||
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
|
||||
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
|
||||
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
|
||||
default: return 'text-blue-700 dark:text-blue-400'
|
||||
}
|
||||
})
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (localEntries.value.length !== serverEntries.value.length) return true
|
||||
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rpm_override]))
|
||||
return localEntries.value.some(e => serverMap.get(e.user_id) !== e.rpm_override)
|
||||
})
|
||||
|
||||
const paginatedLocalEntries = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return localEntries.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
|
||||
const cloneEntries = (entries: GroupRPMOverrideEntry[]): LocalEntry[] => {
|
||||
return entries.map(e => ({ ...e }))
|
||||
}
|
||||
|
||||
const loadEntries = async () => {
|
||||
if (!props.group) return
|
||||
loading.value = true
|
||||
try {
|
||||
serverEntries.value = await adminAPI.groups.getGroupRPMOverrides(props.group.id)
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
adjustPage()
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToLoad'))
|
||||
console.error('Error loading RPM overrides:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const adjustPage = () => {
|
||||
const totalPages = Math.max(1, Math.ceil(localEntries.value.length / pageSize.value))
|
||||
if (currentPage.value > totalPages) currentPage.value = totalPages
|
||||
}
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val && props.group) {
|
||||
currentPage.value = 1
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
selectedUser.value = null
|
||||
newRpm.value = null
|
||||
loadEntries()
|
||||
}
|
||||
})
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
pageSize.value = newSize
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSearchUsers = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
selectedUser.value = null
|
||||
if (!searchQuery.value.trim()) {
|
||||
searchResults.value = []
|
||||
showDropdown.value = false
|
||||
return
|
||||
}
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
|
||||
searchResults.value = res.items
|
||||
showDropdown.value = true
|
||||
} catch {
|
||||
searchResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const selectUser = (user: AdminUser) => {
|
||||
selectedUser.value = user
|
||||
searchQuery.value = user.email
|
||||
showDropdown.value = false
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
const handleAddLocal = () => {
|
||||
if (!selectedUser.value || newRpm.value == null || newRpm.value < 0) return
|
||||
const user = selectedUser.value
|
||||
const idx = localEntries.value.findIndex(e => e.user_id === user.id)
|
||||
const entry: LocalEntry = {
|
||||
user_id: user.id,
|
||||
user_name: user.username || '',
|
||||
user_email: user.email,
|
||||
user_notes: user.notes || '',
|
||||
user_status: user.status || 'active',
|
||||
rpm_override: newRpm.value
|
||||
}
|
||||
if (idx >= 0) {
|
||||
localEntries.value[idx] = entry
|
||||
} else {
|
||||
localEntries.value.push(entry)
|
||||
}
|
||||
searchQuery.value = ''
|
||||
selectedUser.value = null
|
||||
newRpm.value = null
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
const updateLocalRpm = (userId: number, value: string) => {
|
||||
const num = parseInt(value, 10)
|
||||
if (isNaN(num) || num < 0) return
|
||||
const entry = localEntries.value.find(e => e.user_id === userId)
|
||||
if (entry) entry.rpm_override = num
|
||||
}
|
||||
|
||||
const removeLocal = (userId: number) => {
|
||||
localEntries.value = localEntries.value.filter(e => e.user_id !== userId)
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
const clearing = ref(false)
|
||||
const clearAllLocal = async () => {
|
||||
if (!props.group || clearing.value) return
|
||||
clearing.value = true
|
||||
try {
|
||||
await adminAPI.groups.clearGroupRPMOverrides(props.group.id)
|
||||
localEntries.value = []
|
||||
serverEntries.value = []
|
||||
appStore.showSuccess(t('admin.groups.rpmSaved'))
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error clearing RPM overrides:', error)
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!props.group) return
|
||||
saving.value = true
|
||||
try {
|
||||
const entries = localEntries.value.map(e => ({
|
||||
user_id: e.user_id,
|
||||
rpm_override: e.rpm_override
|
||||
}))
|
||||
await adminAPI.groups.batchSetGroupRPMOverrides(props.group.id, entries)
|
||||
appStore.showSuccess(t('admin.groups.rpmSaved'))
|
||||
emit('success')
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
appStore.showError(t('admin.groups.failedToSave'))
|
||||
console.error('Error saving RPM overrides:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty.value) {
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
}
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleClickOutside = () => { showDropdown.value = false }
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hide-spinner::-webkit-outer-spin-button,
|
||||
.hide-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
.hide-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -168,7 +168,8 @@
|
||||
step="0.001"
|
||||
min="0.001"
|
||||
autocomplete="off"
|
||||
:value="entry.rate_multiplier"
|
||||
:value="entry.rate_multiplier ?? ''"
|
||||
:placeholder="String(props.group?.rate_multiplier ?? 1)"
|
||||
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||
@change="updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
@@ -294,19 +295,17 @@ const showFinalRate = computed(() => {
|
||||
})
|
||||
|
||||
// 计算最终倍率预览
|
||||
const computeFinalRate = (rate: number) => {
|
||||
if (!batchFactor.value) return rate
|
||||
return parseFloat((rate * batchFactor.value).toFixed(6))
|
||||
const computeFinalRate = (rate: number | null | undefined) => {
|
||||
const base = rate ?? props.group?.rate_multiplier ?? 1
|
||||
if (!batchFactor.value) return base
|
||||
return parseFloat((base * batchFactor.value).toFixed(6))
|
||||
}
|
||||
|
||||
// 检测是否有未保存的修改
|
||||
const isDirty = computed(() => {
|
||||
if (localEntries.value.length !== serverEntries.value.length) return true
|
||||
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier]))
|
||||
return localEntries.value.some(e => {
|
||||
const serverRate = serverMap.get(e.user_id)
|
||||
return serverRate === undefined || serverRate !== e.rate_multiplier
|
||||
})
|
||||
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier ?? null]))
|
||||
return localEntries.value.some(e => serverMap.get(e.user_id) !== (e.rate_multiplier ?? null))
|
||||
})
|
||||
|
||||
const paginatedLocalEntries = computed(() => {
|
||||
@@ -322,7 +321,9 @@ const loadEntries = async () => {
|
||||
if (!props.group) return
|
||||
loading.value = true
|
||||
try {
|
||||
serverEntries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||
const raw = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
|
||||
// 仅显示已设置 rate_multiplier 的条目;rpm_override 在另一个弹窗管理,保留不动
|
||||
serverEntries.value = raw.filter(e => e.rate_multiplier != null)
|
||||
localEntries.value = cloneEntries(serverEntries.value)
|
||||
adjustPage()
|
||||
} catch (error) {
|
||||
@@ -394,7 +395,8 @@ const handleAddLocal = () => {
|
||||
user_email: user.email,
|
||||
user_notes: user.notes || '',
|
||||
user_status: user.status || 'active',
|
||||
rate_multiplier: newRate.value
|
||||
rate_multiplier: newRate.value,
|
||||
rpm_override: null
|
||||
}
|
||||
if (idx >= 0) {
|
||||
localEntries.value[idx] = entry
|
||||
@@ -409,12 +411,15 @@ const handleAddLocal = () => {
|
||||
|
||||
// 本地修改倍率
|
||||
const updateLocalRate = (userId: number, value: string) => {
|
||||
const entry = localEntries.value.find(e => e.user_id === userId)
|
||||
if (!entry) return
|
||||
if (value.trim() === '') {
|
||||
entry.rate_multiplier = null
|
||||
return
|
||||
}
|
||||
const num = parseFloat(value)
|
||||
if (isNaN(num)) return
|
||||
const entry = localEntries.value.find(e => e.user_id === userId)
|
||||
if (entry) {
|
||||
entry.rate_multiplier = num
|
||||
}
|
||||
entry.rate_multiplier = num
|
||||
}
|
||||
|
||||
// 本地删除
|
||||
@@ -427,7 +432,9 @@ const removeLocal = (userId: number) => {
|
||||
const applyBatchFactor = () => {
|
||||
if (!batchFactor.value || batchFactor.value <= 0) return
|
||||
for (const entry of localEntries.value) {
|
||||
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
|
||||
if (entry.rate_multiplier != null) {
|
||||
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
|
||||
}
|
||||
}
|
||||
batchFactor.value = null
|
||||
}
|
||||
@@ -444,15 +451,17 @@ const handleCancel = () => {
|
||||
adjustPage()
|
||||
}
|
||||
|
||||
// 保存:一次性提交所有数据
|
||||
// 保存:一次性提交所有数据(只提交 rate_multiplier;rpm_override 由独立弹窗管理)
|
||||
const handleSave = async () => {
|
||||
if (!props.group) return
|
||||
saving.value = true
|
||||
try {
|
||||
const entries = localEntries.value.map(e => ({
|
||||
user_id: e.user_id,
|
||||
rate_multiplier: e.rate_multiplier
|
||||
}))
|
||||
const entries = localEntries.value
|
||||
.filter(e => e.rate_multiplier != null)
|
||||
.map(e => ({
|
||||
user_id: e.user_id,
|
||||
rate_multiplier: e.rate_multiplier as number
|
||||
}))
|
||||
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries)
|
||||
appStore.showSuccess(t('admin.groups.rateSaved'))
|
||||
emit('success')
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
|
||||
<input
|
||||
v-model.number="form.rpm_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.form.rpmLimitPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.users.form.rpmLimitHint') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
@@ -57,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue'
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
|
||||
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 })
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form,
|
||||
@@ -68,7 +80,7 @@ const { loading, submit } = useForm({
|
||||
successMsg: t('admin.users.userCreated')
|
||||
})
|
||||
|
||||
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }) })
|
||||
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 }) })
|
||||
|
||||
const generateRandomPassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
|
||||
@@ -37,6 +37,18 @@
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
|
||||
<input
|
||||
v-model.number="form.rpm_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.users.form.rpmLimitPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t('admin.users.form.rpmLimitHint') }}</p>
|
||||
</div>
|
||||
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
|
||||
</form>
|
||||
<template #footer>
|
||||
@@ -66,11 +78,11 @@ const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
|
||||
|
||||
const submitting = ref(false); const passwordCopied = ref(false)
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, rpm_limit: 0, customAttributes: {} as UserAttributeValuesMap })
|
||||
|
||||
watch(() => props.user, (u) => {
|
||||
if (u) {
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, rpm_limit: u.rpm_limit ?? 0, customAttributes: {} })
|
||||
passwordCopied.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
@@ -97,7 +109,7 @@ const handleUpdateUser = async () => {
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
|
||||
if (form.password.trim()) data.password = form.password.trim()
|
||||
await adminAPI.users.update(props.user.id, data)
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
|
||||
|
||||
@@ -894,6 +894,8 @@ export default {
|
||||
description: 'Manage your account information and settings',
|
||||
accountBalance: 'Account Balance',
|
||||
concurrencyLimit: 'Concurrency Limit',
|
||||
rpmLimit: 'RPM Limit',
|
||||
rpmUnlimited: 'Unlimited',
|
||||
memberSince: 'Member Since',
|
||||
overviewTitle: 'Account Overview',
|
||||
overviewDescription: 'Check account status, profile sources, and common actions at a glance.',
|
||||
@@ -1490,6 +1492,11 @@ export default {
|
||||
copyPassword: 'Copy password',
|
||||
creating: 'Creating...',
|
||||
updating: 'Updating...',
|
||||
form: {
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
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.'
|
||||
},
|
||||
columns: {
|
||||
user: 'User',
|
||||
id: 'ID',
|
||||
@@ -1704,6 +1711,10 @@ export default {
|
||||
name: 'Name',
|
||||
platform: 'Platform',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
rpmOverride: 'RPM Override',
|
||||
rpmOverrideHint: 'Per-user RPM cap in this group; empty = group default; 0 = unlimited',
|
||||
rateDefault: 'default',
|
||||
rpmDefault: 'default',
|
||||
type: 'Type',
|
||||
accounts: 'Accounts',
|
||||
capacity: 'Capacity',
|
||||
@@ -1730,7 +1741,10 @@ export default {
|
||||
platform: 'Platform',
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
status: 'Status',
|
||||
exclusive: 'Exclusive Group'
|
||||
exclusive: 'Exclusive Group',
|
||||
rpmLimit: 'Requests Per Minute (RPM)',
|
||||
rpmLimitPlaceholder: '0 = unlimited',
|
||||
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).'
|
||||
},
|
||||
enterGroupName: 'Enter group name',
|
||||
optionalDescription: 'Optional description',
|
||||
@@ -1762,6 +1776,12 @@ export default {
|
||||
rateMultipliers: 'Rate Multipliers',
|
||||
rateMultipliersTitle: 'Group Rate Multipliers',
|
||||
addUserRate: 'Add User Rate Multiplier',
|
||||
rpmOverrides: 'RPM Overrides',
|
||||
rpmOverridesTitle: 'Group RPM Overrides',
|
||||
addUserRpm: 'Add User RPM Override',
|
||||
noRpmOverrides: 'No users have an RPM override yet',
|
||||
rpmSaved: 'RPM overrides saved',
|
||||
groupRpmDefault: 'Group default RPM',
|
||||
searchUserPlaceholder: 'Search user email...',
|
||||
noRateMultipliers: 'No user rate multipliers configured',
|
||||
rateUpdated: 'Rate multiplier updated',
|
||||
@@ -4503,6 +4523,8 @@ export default {
|
||||
defaultBalanceHint: 'Initial balance for new users',
|
||||
defaultConcurrency: 'Default Concurrency',
|
||||
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
|
||||
defaultUserRpmLimit: 'Default User RPM Limit',
|
||||
defaultUserRpmLimitHint: 'Default max requests per minute for new users; 0 = unlimited. Only applied at new user creation.',
|
||||
defaultSubscriptions: 'Default Subscriptions',
|
||||
defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered',
|
||||
addDefaultSubscription: 'Add Default Subscription',
|
||||
|
||||
@@ -898,6 +898,8 @@ export default {
|
||||
description: '管理您的账户信息和设置',
|
||||
accountBalance: '账户余额',
|
||||
concurrencyLimit: '并发限制',
|
||||
rpmLimit: 'RPM 限制',
|
||||
rpmUnlimited: '不限制',
|
||||
memberSince: '注册时间',
|
||||
overviewTitle: '账户总览',
|
||||
overviewDescription: '快速查看账号状态、资料来源与常用设置。',
|
||||
@@ -1589,7 +1591,10 @@ export default {
|
||||
balanceLabel: '余额',
|
||||
concurrencyLabel: '并发数',
|
||||
statusLabel: '状态',
|
||||
selectStatus: '选择状态'
|
||||
selectStatus: '选择状态',
|
||||
rpmLimit: '每分钟请求数 (RPM)',
|
||||
rpmLimitPlaceholder: '0 表示不限制',
|
||||
rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效'
|
||||
},
|
||||
adjustBalance: '调整余额',
|
||||
adjustConcurrency: '调整并发数',
|
||||
@@ -1756,6 +1761,10 @@ export default {
|
||||
name: '名称',
|
||||
platform: '平台',
|
||||
rateMultiplier: '费率倍数',
|
||||
rpmOverride: 'RPM 覆盖',
|
||||
rpmOverrideHint: '该用户在此分组的 RPM 上限;留空 = 使用分组默认;0 = 不限制',
|
||||
rateDefault: '默认',
|
||||
rpmDefault: '默认',
|
||||
exclusive: '独占',
|
||||
type: '类型',
|
||||
priority: '优先级',
|
||||
@@ -1790,6 +1799,9 @@ export default {
|
||||
descriptionPlaceholder: '请输入描述(可选)',
|
||||
rateMultiplierLabel: '费率倍数',
|
||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||
rpmLimit: '每分钟请求数 (RPM)',
|
||||
rpmLimitPlaceholder: '0 表示不限制',
|
||||
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
|
||||
exclusiveLabel: '专属分组',
|
||||
exclusiveHint: '专属分组,可以手动指定给用户',
|
||||
platformLabel: '平台限制',
|
||||
@@ -1859,6 +1871,12 @@ export default {
|
||||
rateMultipliers: '专属倍率',
|
||||
rateMultipliersTitle: '分组专属倍率管理',
|
||||
addUserRate: '添加用户专属倍率',
|
||||
rpmOverrides: '专属 RPM',
|
||||
rpmOverridesTitle: '分组专属 RPM 管理',
|
||||
addUserRpm: '添加用户专属 RPM',
|
||||
noRpmOverrides: '暂无用户设置了专属 RPM',
|
||||
rpmSaved: '专属 RPM 已保存',
|
||||
groupRpmDefault: '分组默认 RPM',
|
||||
searchUserPlaceholder: '搜索用户邮箱...',
|
||||
noRateMultipliers: '暂无用户设置了专属倍率',
|
||||
rateUpdated: '专属倍率已更新',
|
||||
@@ -4668,6 +4686,8 @@ export default {
|
||||
defaultBalanceHint: '新用户的初始余额',
|
||||
defaultConcurrency: '默认并发数',
|
||||
defaultConcurrencyHint: '新用户的最大并发请求数',
|
||||
defaultUserRpmLimit: '默认用户 RPM 限制',
|
||||
defaultUserRpmLimitHint: '新用户默认每分钟最大请求数,0 = 不限制;仅作用于新用户创建时初始化',
|
||||
defaultSubscriptions: '默认订阅列表',
|
||||
defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅',
|
||||
addDefaultSubscription: '添加默认订阅',
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface User {
|
||||
role: 'admin' | 'user' // User role for authorization
|
||||
balance: number // User balance for API usage
|
||||
concurrency: number // Allowed concurrent requests
|
||||
rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit
|
||||
status: 'active' | 'disabled' // Account status
|
||||
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
|
||||
balance_notify_enabled: boolean
|
||||
@@ -453,6 +454,7 @@ export interface Group {
|
||||
description: string | null
|
||||
platform: GroupPlatform
|
||||
rate_multiplier: number
|
||||
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
|
||||
is_exclusive: boolean
|
||||
status: 'active' | 'inactive'
|
||||
subscription_type: SubscriptionType
|
||||
|
||||
@@ -308,6 +308,15 @@
|
||||
t("admin.groups.rateMultipliers")
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleRPMOverrides(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-orange-600 dark:hover:bg-dark-700 dark:hover:text-orange-400"
|
||||
>
|
||||
<Icon name="bolt" size="sm" />
|
||||
<span class="text-xs">{{
|
||||
t("admin.groups.rpmOverrides")
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
@click="handleDelete(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
@@ -491,6 +500,18 @@
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
|
||||
<input
|
||||
v-model.number="createForm.rpm_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="createForm.subscription_type !== 'subscription'"
|
||||
data-tour="group-form-exclusive"
|
||||
@@ -1612,6 +1633,18 @@
|
||||
data-tour="group-form-multiplier"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
|
||||
<input
|
||||
v-model.number="editForm.rpm_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
|
||||
/>
|
||||
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
|
||||
</div>
|
||||
<div v-if="editForm.subscription_type !== 'subscription'">
|
||||
<div class="mb-1.5 flex items-center gap-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -2689,6 +2722,14 @@
|
||||
@close="showRateMultipliersModal = false"
|
||||
@success="loadGroups"
|
||||
/>
|
||||
|
||||
<!-- Group RPM Overrides Modal -->
|
||||
<GroupRPMOverridesModal
|
||||
:show="showRPMOverridesModal"
|
||||
:group="rpmOverridesGroup"
|
||||
@close="showRPMOverridesModal = false"
|
||||
@success="loadGroups"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -2711,6 +2752,7 @@ import Select from "@/components/common/Select.vue";
|
||||
import PlatformIcon from "@/components/common/PlatformIcon.vue";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
|
||||
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
|
||||
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
|
||||
@@ -2951,6 +2993,8 @@ const editingGroup = ref<AdminGroup | null>(null);
|
||||
const deletingGroup = ref<AdminGroup | null>(null);
|
||||
const showRateMultipliersModal = ref(false);
|
||||
const rateMultipliersGroup = ref<AdminGroup | null>(null);
|
||||
const showRPMOverridesModal = ref(false);
|
||||
const rpmOverridesGroup = ref<AdminGroup | null>(null);
|
||||
const sortableGroups = ref<AdminGroup[]>([]);
|
||||
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
|
||||
@@ -2990,6 +3034,8 @@ const createForm = reactive({
|
||||
mcp_xml_inject: true,
|
||||
// 从分组复制账号
|
||||
copy_accounts_from_group_ids: [] as number[],
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
});
|
||||
|
||||
// 简单账号类型(用于模型路由选择)
|
||||
@@ -3271,6 +3317,8 @@ const editForm = reactive({
|
||||
mcp_xml_inject: true,
|
||||
// 从分组复制账号
|
||||
copy_accounts_from_group_ids: [] as number[],
|
||||
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
|
||||
rpm_limit: 0 as number,
|
||||
});
|
||||
|
||||
// 根据分组类型返回不同的删除确认消息
|
||||
@@ -3562,6 +3610,7 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
];
|
||||
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
|
||||
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
|
||||
editForm.rpm_limit = group.rpm_limit ?? 0;
|
||||
// 加载模型路由规则(异步加载账号名称)
|
||||
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
|
||||
group.model_routing,
|
||||
@@ -3670,6 +3719,11 @@ const handleRateMultipliers = (group: AdminGroup) => {
|
||||
showRateMultipliersModal.value = true;
|
||||
};
|
||||
|
||||
const handleRPMOverrides = (group: AdminGroup) => {
|
||||
rpmOverridesGroup.value = group;
|
||||
showRPMOverridesModal.value = true;
|
||||
};
|
||||
|
||||
const handleDelete = (group: AdminGroup) => {
|
||||
deletingGroup.value = group;
|
||||
showDeleteDialog.value = true;
|
||||
|
||||
@@ -2170,6 +2170,24 @@
|
||||
{{ t("admin.settings.defaults.defaultConcurrencyHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.defaults.defaultUserRpmLimit") }}
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.default_user_rpm_limit"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
class="input"
|
||||
placeholder="0"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.defaults.defaultUserRpmLimitHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
@@ -4867,6 +4885,7 @@ const form = reactive<SettingsForm>({
|
||||
default_concurrency: 1,
|
||||
default_subscriptions: [],
|
||||
force_email_on_third_party_signup: false,
|
||||
default_user_rpm_limit: 0,
|
||||
site_name: "Sub2API",
|
||||
site_logo: "",
|
||||
site_subtitle: "Subscription to API Conversion Platform",
|
||||
@@ -5783,6 +5802,7 @@ async function saveSettings() {
|
||||
default_concurrency: form.default_concurrency,
|
||||
default_subscriptions: normalizedDefaultSubscriptions,
|
||||
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
|
||||
default_user_rpm_limit: form.default_user_rpm_limit,
|
||||
site_name: form.site_name,
|
||||
site_logo: form.site_logo,
|
||||
site_subtitle: form.site_subtitle,
|
||||
|
||||
Reference in New Issue
Block a user