mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
feat(admin): 分组管理新增容量列(并发/会话/RPM 实时聚合)
复用 GroupCapacityService,在 admin 分组列表中添加容量列, 显示每个分组的实时并发/会话/RPM 使用量和上限。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -234,6 +234,18 @@ export async function getUsageSummary(
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capacity summary (concurrency/sessions/RPM) for all active groups
|
||||
*/
|
||||
export async function getCapacitySummary(): Promise<
|
||||
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||
> {
|
||||
const { data } = await apiClient.get<
|
||||
{ group_id: number; concurrency_used: number; concurrency_max: number; sessions_used: number; sessions_max: number; rpm_used: number; rpm_max: number }[]
|
||||
>('/admin/groups/capacity-summary')
|
||||
return data
|
||||
}
|
||||
|
||||
export const groupsAPI = {
|
||||
list,
|
||||
getAll,
|
||||
@@ -249,7 +261,8 @@ export const groupsAPI = {
|
||||
clearGroupRateMultipliers,
|
||||
batchSetGroupRateMultipliers,
|
||||
updateSortOrder,
|
||||
getUsageSummary
|
||||
getUsageSummary,
|
||||
getCapacitySummary
|
||||
}
|
||||
|
||||
export default groupsAPI
|
||||
|
||||
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
84
frontend/src/components/common/GroupCapacityBadge.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- 并发槽位 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(concurrencyUsed, concurrencyMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ concurrencyUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ concurrencyMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 会话数 -->
|
||||
<div v-if="sessionsMax > 0" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(sessionsUsed, sessionsMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ sessionsUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ sessionsMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- RPM -->
|
||||
<div v-if="rpmMax > 0" class="flex items-center gap-1">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-medium',
|
||||
capacityClass(rpmUsed, rpmMax)
|
||||
]"
|
||||
>
|
||||
<svg class="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
<span class="font-mono">{{ rpmUsed }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">/</span>
|
||||
<span class="font-mono">{{ rpmMax }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
concurrencyUsed: number
|
||||
concurrencyMax: number
|
||||
sessionsUsed: number
|
||||
sessionsMax: number
|
||||
rpmUsed: number
|
||||
rpmMax: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
concurrencyUsed: 0,
|
||||
concurrencyMax: 0,
|
||||
sessionsUsed: 0,
|
||||
sessionsMax: 0,
|
||||
rpmUsed: 0,
|
||||
rpmMax: 0
|
||||
})
|
||||
|
||||
function capacityClass(used: number, max: number): string {
|
||||
if (max > 0 && used >= max) {
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}
|
||||
if (used > 0) {
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||
}
|
||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}
|
||||
</script>
|
||||
@@ -1505,6 +1505,7 @@ export default {
|
||||
rateMultiplier: 'Rate Multiplier',
|
||||
type: 'Type',
|
||||
accounts: 'Accounts',
|
||||
capacity: 'Capacity',
|
||||
usage: 'Usage',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
|
||||
@@ -1561,6 +1561,7 @@ export default {
|
||||
priority: '优先级',
|
||||
apiKeys: 'API 密钥数',
|
||||
accounts: '账号数',
|
||||
capacity: '容量',
|
||||
usage: '用量',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
|
||||
@@ -178,6 +178,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-capacity="{ row }">
|
||||
<GroupCapacityBadge
|
||||
v-if="capacityMap.get(row.id)"
|
||||
:concurrency-used="capacityMap.get(row.id)!.concurrencyUsed"
|
||||
:concurrency-max="capacityMap.get(row.id)!.concurrencyMax"
|
||||
:sessions-used="capacityMap.get(row.id)!.sessionsUsed"
|
||||
:sessions-max="capacityMap.get(row.id)!.sessionsMax"
|
||||
:rpm-used="capacityMap.get(row.id)!.rpmUsed"
|
||||
:rpm-max="capacityMap.get(row.id)!.rpmMax"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-400">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-usage="{ row }">
|
||||
<div v-if="usageLoading" class="text-xs text-gray-400">—</div>
|
||||
<div v-else class="space-y-0.5 text-xs">
|
||||
@@ -1838,6 +1851,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 GroupCapacityBadge from '@/components/common/GroupCapacityBadge.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
@@ -1853,6 +1867,7 @@ const columns = computed<Column[]>(() => [
|
||||
{ key: 'rate_multiplier', label: t('admin.groups.columns.rateMultiplier'), sortable: true },
|
||||
{ key: 'is_exclusive', label: t('admin.groups.columns.type'), sortable: true },
|
||||
{ key: 'account_count', label: t('admin.groups.columns.accounts'), sortable: true },
|
||||
{ key: 'capacity', label: t('admin.groups.columns.capacity'), sortable: false },
|
||||
{ key: 'usage', label: t('admin.groups.columns.usage'), sortable: false },
|
||||
{ key: 'status', label: t('admin.groups.columns.status'), sortable: true },
|
||||
{ key: 'actions', label: t('admin.groups.columns.actions'), sortable: false }
|
||||
@@ -1992,6 +2007,7 @@ const groups = ref<AdminGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const usageMap = ref<Map<number, { today_cost: number; total_cost: number }>>(new Map())
|
||||
const usageLoading = ref(false)
|
||||
const capacityMap = ref<Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>>(new Map())
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
@@ -2331,6 +2347,7 @@ const loadGroups = async () => {
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
loadUsageSummary()
|
||||
loadCapacitySummary()
|
||||
} catch (error: any) {
|
||||
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
@@ -2367,6 +2384,26 @@ const loadUsageSummary = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadCapacitySummary = async () => {
|
||||
try {
|
||||
const data = await adminAPI.groups.getCapacitySummary()
|
||||
const map = new Map<number, { concurrencyUsed: number; concurrencyMax: number; sessionsUsed: number; sessionsMax: number; rpmUsed: number; rpmMax: number }>()
|
||||
for (const item of data) {
|
||||
map.set(item.group_id, {
|
||||
concurrencyUsed: item.concurrency_used,
|
||||
concurrencyMax: item.concurrency_max,
|
||||
sessionsUsed: item.sessions_used,
|
||||
sessionsMax: item.sessions_max,
|
||||
rpmUsed: item.rpm_used,
|
||||
rpmMax: item.rpm_max
|
||||
})
|
||||
}
|
||||
capacityMap.value = map
|
||||
} catch (error) {
|
||||
console.error('Error loading group capacity summary:', error)
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
const handleSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
|
||||
Reference in New Issue
Block a user