mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-13 19:34:45 +08:00
Merge upstream/main into pr/upstream-model-tracking
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -158,12 +158,51 @@
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account_count="{ value }">
|
||||
<span
|
||||
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300"
|
||||
>
|
||||
{{ t('admin.groups.accountsCount', { count: value || 0 }) }}
|
||||
</span>
|
||||
<template #cell-account_count="{ row }">
|
||||
<div class="space-y-0.5 text-xs">
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsAvailable') }}</span>
|
||||
<span class="ml-1 font-medium text-emerald-600 dark:text-emerald-400">{{ (row.active_account_count || 0) - (row.rate_limited_account_count || 0) }}</span>
|
||||
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||
</div>
|
||||
<div v-if="row.rate_limited_account_count">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsRateLimited') }}</span>
|
||||
<span class="ml-1 font-medium text-amber-600 dark:text-amber-400">{{ row.rate_limited_account_count }}</span>
|
||||
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.groups.accountsTotal') }}</span>
|
||||
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">{{ row.account_count || 0 }}</span>
|
||||
<span class="ml-1 inline-flex items-center rounded bg-gray-100 px-1.5 py-0.5 font-medium text-gray-800 dark:bg-dark-600 dark:text-gray-300">{{ t('admin.groups.accountsUnit') }}</span>
|
||||
</div>
|
||||
</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">
|
||||
<div class="text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageToday') }}</span>
|
||||
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.today_cost ?? 0) }}</span>
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ t('admin.groups.usageTotal') }}</span>
|
||||
<span class="ml-1 font-medium text-gray-700 dark:text-gray-300">${{ formatCost(usageMap.get(row.id)?.total_cost ?? 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
@@ -1812,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'
|
||||
@@ -1827,6 +1867,8 @@ 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 }
|
||||
])
|
||||
@@ -1963,6 +2005,9 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
|
||||
|
||||
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: '',
|
||||
@@ -2301,6 +2346,8 @@ const loadGroups = async () => {
|
||||
groups.value = response.items
|
||||
pagination.total = response.total
|
||||
pagination.pages = response.pages
|
||||
loadUsageSummary()
|
||||
loadCapacitySummary()
|
||||
} catch (error: any) {
|
||||
if (signal.aborted || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') {
|
||||
return
|
||||
@@ -2314,6 +2361,49 @@ const loadGroups = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
if (cost >= 1000) return cost.toFixed(0)
|
||||
if (cost >= 100) return cost.toFixed(1)
|
||||
return cost.toFixed(2)
|
||||
}
|
||||
|
||||
const loadUsageSummary = async () => {
|
||||
usageLoading.value = true
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const data = await adminAPI.groups.getUsageSummary(tz)
|
||||
const map = new Map<number, { today_cost: number; total_cost: number }>()
|
||||
for (const item of data) {
|
||||
map.set(item.group_id, { today_cost: item.today_cost, total_cost: item.total_cost })
|
||||
}
|
||||
usageMap.value = map
|
||||
} catch (error) {
|
||||
console.error('Error loading group usage summary:', error)
|
||||
} finally {
|
||||
usageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -81,6 +81,14 @@
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
:placeholder="t('admin.subscriptions.allPlatforms')"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
@@ -144,6 +152,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="showGuideModal = true"
|
||||
class="btn btn-secondary"
|
||||
:title="t('admin.subscriptions.guide.showGuide')"
|
||||
>
|
||||
<Icon name="questionCircle" size="md" />
|
||||
</button>
|
||||
<button @click="showAssignModal = true" class="btn btn-primary">
|
||||
<Icon name="plus" size="md" class="mr-2" />
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
@@ -638,6 +653,85 @@
|
||||
@confirm="confirmResetQuota"
|
||||
@cancel="showResetQuotaConfirm = false"
|
||||
/>
|
||||
<!-- Subscription Guide Modal -->
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div v-if="showGuideModal" class="fixed inset-0 z-50 flex items-center justify-center p-4" @mousedown.self="showGuideModal = false">
|
||||
<div class="fixed inset-0 bg-black/50" @click="showGuideModal = false"></div>
|
||||
<div class="relative max-h-[85vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800">
|
||||
<button type="button" class="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200" @click="showGuideModal = false">
|
||||
<svg class="h-5 w-5" 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>
|
||||
|
||||
<h2 class="mb-4 text-lg font-bold text-gray-900 dark:text-white">{{ t('admin.subscriptions.guide.title') }}</h2>
|
||||
<p class="mb-5 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.subscriptions.guide.subtitle') }}</p>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">1</span>
|
||||
{{ t('admin.subscriptions.guide.step1.title') }}
|
||||
</h3>
|
||||
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li>{{ t('admin.subscriptions.guide.step1.line1') }}</li>
|
||||
<li>{{ t('admin.subscriptions.guide.step1.line2') }}</li>
|
||||
<li>{{ t('admin.subscriptions.guide.step1.line3') }}</li>
|
||||
</ol>
|
||||
<div class="ml-8 mt-2">
|
||||
<router-link
|
||||
to="/admin/groups"
|
||||
@click="showGuideModal = false"
|
||||
class="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
{{ t('admin.subscriptions.guide.step1.link') }}
|
||||
<Icon name="arrowRight" size="xs" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">2</span>
|
||||
{{ t('admin.subscriptions.guide.step2.title') }}
|
||||
</h3>
|
||||
<ol class="ml-8 list-decimal space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li>{{ t('admin.subscriptions.guide.step2.line1') }}</li>
|
||||
<li>{{ t('admin.subscriptions.guide.step2.line2') }}</li>
|
||||
<li>{{ t('admin.subscriptions.guide.step2.line3') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="mb-5">
|
||||
<h3 class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-bold text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">3</span>
|
||||
{{ t('admin.subscriptions.guide.step3.title') }}
|
||||
</h3>
|
||||
<div class="ml-8 overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
|
||||
<table class="w-full text-sm">
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in guideActionRows" :key="i" class="border-b border-gray-100 dark:border-dark-700 last:border-0">
|
||||
<td class="whitespace-nowrap bg-gray-50 px-3 py-2 font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-300">{{ row.action }}</td>
|
||||
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ row.desc }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip -->
|
||||
<div class="rounded-lg bg-blue-50 p-3 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
{{ t('admin.subscriptions.guide.tip') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-right">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="showGuideModal = false">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
@@ -674,6 +768,15 @@ interface GroupOption {
|
||||
rate: number
|
||||
}
|
||||
|
||||
// Guide modal state
|
||||
const showGuideModal = ref(false)
|
||||
|
||||
const guideActionRows = computed(() => [
|
||||
{ action: t('admin.subscriptions.guide.actions.adjust'), desc: t('admin.subscriptions.guide.actions.adjustDesc') },
|
||||
{ action: t('admin.subscriptions.guide.actions.resetQuota'), desc: t('admin.subscriptions.guide.actions.resetQuotaDesc') },
|
||||
{ action: t('admin.subscriptions.guide.actions.revoke'), desc: t('admin.subscriptions.guide.actions.revokeDesc') }
|
||||
])
|
||||
|
||||
// User column display mode: 'email' or 'username'
|
||||
const userColumnMode = ref<'email' | 'username'>('email')
|
||||
const USER_COLUMN_MODE_KEY = 'subscription-user-column-mode'
|
||||
@@ -813,6 +916,7 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const filters = reactive({
|
||||
status: 'active',
|
||||
group_id: '',
|
||||
platform: '',
|
||||
user_id: null as number | null
|
||||
})
|
||||
|
||||
@@ -855,6 +959,15 @@ const groupOptions = computed(() => [
|
||||
...groups.value.map((g) => ({ value: g.id.toString(), label: g.name }))
|
||||
])
|
||||
|
||||
const platformFilterOptions = computed(() => [
|
||||
{ value: '', label: t('admin.subscriptions.allPlatforms') },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' },
|
||||
{ value: 'antigravity', label: 'Antigravity' },
|
||||
{ value: 'sora', label: 'Sora' }
|
||||
])
|
||||
|
||||
// Group options for assign (only subscription type groups)
|
||||
const subscriptionGroupOptions = computed(() =>
|
||||
groups.value
|
||||
@@ -890,6 +1003,7 @@ const loadSubscriptions = async () => {
|
||||
{
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
||||
platform: filters.platform || undefined,
|
||||
user_id: filters.user_id || undefined,
|
||||
sort_by: sortState.sort_by,
|
||||
sort_order: sortState.sort_order
|
||||
|
||||
Reference in New Issue
Block a user