merge: 合并 main 分支到 test,解决 config 和 modelWhitelist 冲突

- config.go: 保留 Sora 配置,合入 SubscriptionCache 配置
- useModelWhitelist.ts: 同时保留 soraModels 和 antigravityModels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-02-07 20:18:07 +08:00
156 changed files with 14550 additions and 2206 deletions

View File

@@ -16,16 +16,6 @@
@sync="showSync = true"
@create="showCreate = true"
>
<template #before>
<button
@click="showErrorPassthrough = true"
class="btn btn-secondary"
:title="t('admin.errorPassthrough.title')"
>
<Icon name="shield" size="md" class="mr-1.5" />
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
</button>
</template>
<template #after>
<!-- Auto Refresh Dropdown -->
<div class="relative" ref="autoRefreshDropdownRef">
@@ -72,6 +62,16 @@
</div>
</div>
<!-- Error Passthrough Rules -->
<button
@click="showErrorPassthrough = true"
class="btn btn-secondary"
:title="t('admin.errorPassthrough.title')"
>
<Icon name="shield" size="md" class="mr-1.5" />
<span class="hidden md:inline">{{ t('admin.errorPassthrough.title') }}</span>
</button>
<!-- Column Settings Dropdown -->
<div class="relative" ref="columnDropdownRef">
<button
@@ -106,6 +106,14 @@
</div>
</div>
</template>
<template #beforeCreate>
<button @click="showImportData = true" class="btn btn-secondary">
{{ t('admin.accounts.dataImport') }}
</button>
<button @click="openExportDataDialog" class="btn btn-secondary">
{{ selIds.length ? t('admin.accounts.dataExportSelected') : t('admin.accounts.dataExport') }}
</button>
</template>
</AccountTableActions>
</div>
</template>
@@ -120,6 +128,15 @@
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
>
<template #header-select>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:checked="allVisibleSelected"
@click.stop
@change="toggleSelectAllVisible($event)"
/>
</template>
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
@@ -228,9 +245,16 @@
<AccountStatsModal :show="showStats" :account="statsAcc" @close="closeStatsModal" />
<AccountActionMenu :show="menu.show" :account="menu.acc" :position="menu.pos" @close="menu.show = false" @test="handleTest" @stats="handleViewStats" @reauth="handleReAuth" @refresh-token="handleRefresh" @reset-status="handleResetStatus" @clear-rate-limit="handleClearRateLimit" />
<SyncFromCrsModal :show="showSync" @close="showSync = false" @synced="reload" />
<ImportDataModal :show="showImportData" @close="showImportData = false" @imported="handleDataImported" />
<BulkEditAccountModal :show="showBulkEdit" :account-ids="selIds" :proxies="proxies" :groups="groups" @close="showBulkEdit = false" @updated="handleBulkUpdated" />
<TempUnschedStatusModal :show="showTempUnsched" :account="tempUnschedAcc" @close="showTempUnsched = false" @reset="handleTempUnschedReset" />
<ConfirmDialog :show="showDeleteDialog" :title="t('admin.accounts.deleteAccount')" :message="t('admin.accounts.deleteConfirm', { name: deletingAcc?.name })" :confirm-text="t('common.delete')" :cancel-text="t('common.cancel')" :danger="true" @confirm="confirmDelete" @cancel="showDeleteDialog = false" />
<ConfirmDialog :show="showExportDataDialog" :title="t('admin.accounts.dataExport')" :message="t('admin.accounts.dataExportConfirmMessage')" :confirm-text="t('admin.accounts.dataExportConfirm')" :cancel-text="t('common.cancel')" @confirm="handleExportData" @cancel="showExportDataDialog = false">
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" v-model="includeProxyOnExport" />
<span>{{ t('admin.accounts.dataExportIncludeProxies') }}</span>
</label>
</ConfirmDialog>
<ErrorPassthroughRulesModal :show="showErrorPassthrough" @close="showErrorPassthrough = false" />
</AppLayout>
</template>
@@ -253,6 +277,7 @@ import AccountTableActions from '@/components/admin/account/AccountTableActions.
import AccountTableFilters from '@/components/admin/account/AccountTableFilters.vue'
import AccountBulkActionsBar from '@/components/admin/account/AccountBulkActionsBar.vue'
import AccountActionMenu from '@/components/admin/account/AccountActionMenu.vue'
import ImportDataModal from '@/components/admin/account/ImportDataModal.vue'
import ReAuthAccountModal from '@/components/admin/account/ReAuthAccountModal.vue'
import AccountTestModal from '@/components/admin/account/AccountTestModal.vue'
import AccountStatsModal from '@/components/admin/account/AccountStatsModal.vue'
@@ -277,6 +302,9 @@ const selIds = ref<number[]>([])
const showCreate = ref(false)
const showEdit = ref(false)
const showSync = ref(false)
const showImportData = ref(false)
const showExportDataDialog = ref(false)
const includeProxyOnExport = ref(true)
const showBulkEdit = ref(false)
const showTempUnsched = ref(false)
const showDeleteDialog = ref(false)
@@ -292,6 +320,7 @@ const testingAcc = ref<Account | null>(null)
const statsAcc = ref<Account | null>(null)
const togglingSchedulable = ref<number | null>(null)
const menu = reactive<{show:boolean, acc:Account|null, pos:{top:number, left:number}|null}>({ show: false, acc: null, pos: null })
const exportingData = ref(false)
// Column settings
const showColumnDropdown = ref(false)
@@ -418,12 +447,15 @@ const isAnyModalOpen = computed(() => {
showCreate.value ||
showEdit.value ||
showSync.value ||
showImportData.value ||
showExportDataDialog.value ||
showBulkEdit.value ||
showTempUnsched.value ||
showDeleteDialog.value ||
showReAuth.value ||
showTest.value ||
showStats.value
showStats.value ||
showErrorPassthrough.value
)
})
@@ -542,6 +574,21 @@ const openMenu = (a: Account, e: MouseEvent) => {
menu.show = true
}
const toggleSel = (id: number) => { const i = selIds.value.indexOf(id); if(i === -1) selIds.value.push(id); else selIds.value.splice(i, 1) }
const allVisibleSelected = computed(() => {
if (accounts.value.length === 0) return false
return accounts.value.every(account => selIds.value.includes(account.id))
})
const toggleSelectAllVisible = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.checked) {
const next = new Set(selIds.value)
accounts.value.forEach(account => next.add(account.id))
selIds.value = Array.from(next)
return
}
const visibleIds = new Set(accounts.value.map(account => account.id))
selIds.value = selIds.value.filter(id => !visibleIds.has(id))
}
const selectPage = () => { selIds.value = [...new Set([...selIds.value, ...accounts.value.map(a => a.id)])] }
const handleBulkDelete = async () => { if(!confirm(t('common.confirm'))) return; try { await Promise.all(selIds.value.map(id => adminAPI.accounts.delete(id))); selIds.value = []; reload() } catch (error) { console.error('Failed to bulk delete accounts:', error) } }
const updateSchedulableInList = (accountIds: number[], schedulable: boolean) => {
@@ -646,6 +693,50 @@ const handleBulkToggleSchedulable = async (schedulable: boolean) => {
}
}
const handleBulkUpdated = () => { showBulkEdit.value = false; selIds.value = []; reload() }
const handleDataImported = () => { showImportData.value = false; reload() }
const formatExportTimestamp = () => {
const now = new Date()
const pad2 = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
}
const openExportDataDialog = () => {
includeProxyOnExport.value = true
showExportDataDialog.value = true
}
const handleExportData = async () => {
if (exportingData.value) return
exportingData.value = true
try {
const dataPayload = await adminAPI.accounts.exportData(
selIds.value.length > 0
? { ids: selIds.value, includeProxies: includeProxyOnExport.value }
: {
includeProxies: includeProxyOnExport.value,
filters: {
platform: params.platform,
type: params.type,
status: params.status,
search: params.search
}
}
)
const timestamp = formatExportTimestamp()
const filename = `sub2api-account-${timestamp}.json`
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
appStore.showSuccess(t('admin.accounts.dataExported'))
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.dataExportFailed'))
} finally {
exportingData.value = false
showExportDataDialog.value = false
}
}
const closeTestModal = () => { showTest.value = false; testingAcc.value = null }
const closeStatsModal = () => { showStats.value = false; statsAcc.value = null }
const closeReAuthModal = () => { showReAuth.value = false; reAuthAcc.value = null }

View File

@@ -2,47 +2,9 @@
<AppLayout>
<TablePageLayout>
<template #filters>
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
<div class="flex flex-wrap items-start justify-between gap-4">
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
<div class="flex flex-1 flex-wrap items-center gap-3">
<!-- Search -->
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.proxies.searchProxies')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<!-- Filters -->
<div class="w-full sm:w-40">
<Select
v-model="filters.protocol"
:options="protocolOptions"
:placeholder="t('admin.proxies.allProtocols')"
@change="loadProxies"
/>
</div>
<div class="w-full sm:w-36">
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.proxies.allStatus')"
@change="loadProxies"
/>
</div>
</div>
<!-- Right: Actions -->
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
<div class="space-y-3">
<!-- Row 1: Actions -->
<div class="flex flex-wrap items-center gap-3">
<button
@click="loadProxies"
:disabled="loading"
@@ -69,11 +31,52 @@
<Icon name="trash" size="md" class="mr-2" />
{{ t('admin.proxies.batchDeleteAction') }}
</button>
<button @click="showImportData = true" class="btn btn-secondary">
{{ t('admin.proxies.dataImport') }}
</button>
<button @click="showExportDataDialog = true" class="btn btn-secondary">
{{ selectedCount > 0 ? t('admin.proxies.dataExportSelected') : t('admin.proxies.dataExport') }}
</button>
<button @click="showCreateModal = true" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.proxies.createProxy') }}
</button>
</div>
<!-- Row 2: Search + Filters -->
<div class="flex flex-wrap items-center gap-3">
<div class="relative w-full sm:w-64">
<Icon
name="search"
size="md"
class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="t('admin.proxies.searchProxies')"
class="input pl-10"
@input="handleSearch"
/>
</div>
<div class="w-full sm:w-40">
<Select
v-model="filters.protocol"
:options="protocolOptions"
:placeholder="t('admin.proxies.allProtocols')"
@change="loadProxies"
/>
</div>
<div class="w-full sm:w-36">
<Select
v-model="filters.status"
:options="statusOptions"
:placeholder="t('admin.proxies.allStatus')"
@change="loadProxies"
/>
</div>
</div>
</div>
</template>
@@ -606,6 +609,21 @@
@confirm="confirmBatchDelete"
@cancel="showBatchDeleteDialog = false"
/>
<ConfirmDialog
:show="showExportDataDialog"
:title="t('admin.proxies.dataExport')"
:message="t('admin.proxies.dataExportConfirmMessage')"
:confirm-text="t('admin.proxies.dataExportConfirm')"
:cancel-text="t('common.cancel')"
@confirm="handleExportData"
@cancel="showExportDataDialog = false"
/>
<ImportDataModal
:show="showImportData"
@close="showImportData = false"
@imported="handleDataImported"
/>
<!-- Proxy Accounts Dialog -->
<BaseDialog
@@ -668,6 +686,7 @@ import Pagination from '@/components/common/Pagination.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import ImportDataModal from '@/components/admin/proxy/ImportDataModal.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformTypeBadge from '@/components/common/PlatformTypeBadge.vue'
@@ -731,10 +750,13 @@ const pagination = reactive({
const showCreateModal = ref(false)
const showEditModal = ref(false)
const showImportData = ref(false)
const showDeleteDialog = ref(false)
const showBatchDeleteDialog = ref(false)
const showExportDataDialog = ref(false)
const showAccountsModal = ref(false)
const submitting = ref(false)
const exportingData = ref(false)
const testingProxyIds = ref<Set<number>>(new Set())
const batchTesting = ref(false)
const selectedProxyIds = ref<Set<number>>(new Set())
@@ -888,6 +910,11 @@ const closeCreateModal = () => {
batchParseResult.proxies = []
}
const handleDataImported = () => {
showImportData.value = false
loadProxies()
}
// Parse proxy URL: protocol://user:pass@host:port or protocol://host:port
const parseProxyUrl = (
line: string
@@ -1228,6 +1255,45 @@ const handleBatchTest = async () => {
}
}
const formatExportTimestamp = () => {
const now = new Date()
const pad2 = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}`
}
const handleExportData = async () => {
if (exportingData.value) return
exportingData.value = true
try {
const dataPayload = await adminAPI.proxies.exportData(
selectedCount.value > 0
? { ids: Array.from(selectedProxyIds.value) }
: {
filters: {
protocol: filters.protocol || undefined,
status: (filters.status || undefined) as 'active' | 'inactive' | undefined,
search: searchQuery.value || undefined
}
}
)
const timestamp = formatExportTimestamp()
const filename = `sub2api-proxy-${timestamp}.json`
const blob = new Blob([JSON.stringify(dataPayload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
appStore.showSuccess(t('admin.proxies.dataExported'))
} catch (error: any) {
appStore.showError(error?.message || t('admin.proxies.dataExportFailed'))
} finally {
exportingData.value = false
showExportDataDialog.value = false
}
}
const handleDelete = (proxy: Proxy) => {
if ((proxy.account_count || 0) > 0) {
appStore.showError(t('admin.proxies.deleteBlockedInUse'))

View File

@@ -17,7 +17,7 @@
<TokenUsageTrend :trend-data="trendData" :loading="chartsLoading" />
</div>
</div>
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
<UsageFilters v-model="filters" v-model:startDate="startDate" v-model:endDate="endDate" :exporting="exporting" @change="applyFilters" @refresh="refreshData" @reset="resetFilters" @cleanup="openCleanupDialog" @export="exportToExcel" />
<UsageTable :data="usageLogs" :loading="loading" />
<Pagination v-if="pagination.total > 0" :page="pagination.page" :total="pagination.total" :page-size="pagination.page_size" @update:page="handlePageChange" @update:pageSize="handlePageSizeChange" />
</div>
@@ -83,6 +83,7 @@ const loadChartData = async () => {
} catch (error) { console.error('Failed to load chart data:', error) } finally { chartsLoading.value = false }
}
const applyFilters = () => { pagination.page = 1; loadLogs(); loadStats(); loadChartData() }
const refreshData = () => { loadLogs(); loadStats(); loadChartData() }
const resetFilters = () => { startDate.value = formatLD(weekAgo); endDate.value = formatLD(now); filters.value = { start_date: startDate.value, end_date: endDate.value, billing_type: null }; granularity.value = 'day'; applyFilters() }
const handlePageChange = (p: number) => { pagination.page = p; loadLogs() }
const handlePageSizeChange = (s: number) => { pagination.page_size = s; pagination.page = 1; loadLogs() }

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse, type OpsUserConcurrencyStatsResponse } from '@/api/admin/ops'
interface Props {
platformFilter?: string
@@ -20,6 +20,10 @@ const loading = ref(false)
const errorMessage = ref('')
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
const userConcurrency = ref<OpsUserConcurrencyStatsResponse | null>(null)
// 用户视图开关
const showByUser = ref(false)
const realtimeEnabled = computed(() => {
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
@@ -30,7 +34,10 @@ function safeNumber(n: unknown): number {
}
// 计算显示维度
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
const displayDimension = computed<'platform' | 'group' | 'account' | 'user'>(() => {
if (showByUser.value) {
return 'user'
}
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
return 'account'
}
@@ -81,6 +88,18 @@ interface AccountRow {
error_message?: string
}
// 用户行数据
interface UserRow {
key: string
user_id: number
user_email: string
username: string
current_in_use: number
max_capacity: number
waiting_in_queue: number
load_percentage: number
}
// 平台维度汇总
const platformRows = computed((): SummaryRow[] => {
const concStats = concurrency.value?.platform || {}
@@ -205,14 +224,37 @@ const accountRows = computed((): AccountRow[] => {
})
})
// 用户维度详细
const userRows = computed((): UserRow[] => {
const userStats = userConcurrency.value?.user || {}
return Object.keys(userStats)
.map(uid => {
const u = userStats[uid] || {}
return {
key: uid,
user_id: safeNumber(u.user_id),
user_email: u.user_email || `User ${uid}`,
username: u.username || '',
current_in_use: safeNumber(u.current_in_use),
max_capacity: safeNumber(u.max_capacity),
waiting_in_queue: safeNumber(u.waiting_in_queue),
load_percentage: safeNumber(u.load_percentage)
}
})
.sort((a, b) => b.current_in_use - a.current_in_use || b.load_percentage - a.load_percentage)
})
// 根据维度选择数据
const displayRows = computed(() => {
if (displayDimension.value === 'user') return userRows.value
if (displayDimension.value === 'account') return accountRows.value
if (displayDimension.value === 'group') return groupRows.value
return platformRows.value
})
const displayTitle = computed(() => {
if (displayDimension.value === 'user') return t('admin.ops.concurrency.byUser')
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
return t('admin.ops.concurrency.byPlatform')
@@ -222,12 +264,19 @@ async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const [concData, availData] = await Promise.all([
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
])
concurrency.value = concData
availability.value = availData
if (showByUser.value) {
// 用户视图模式只加载用户并发数据
const userData = await opsAPI.getUserConcurrencyStats()
userConcurrency.value = userData
} else {
// 常规模式加载账号/平台/分组数据
const [concData, availData] = await Promise.all([
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
])
concurrency.value = concData
availability.value = availData
}
} catch (err: any) {
console.error('[OpsConcurrencyCard] Failed to load data', err)
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
@@ -245,6 +294,14 @@ watch(
}
)
// 切换用户视图时重新加载数据
watch(
() => showByUser.value,
() => {
loadData()
}
)
function getLoadBarClass(loadPct: number): string {
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
@@ -302,16 +359,32 @@ watch(
</svg>
{{ t('admin.ops.concurrency.title') }}
</h3>
<button
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="loadData"
>
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<div class="flex items-center gap-2">
<!-- 用户视图切换按钮 -->
<button
class="flex items-center justify-center rounded-lg px-2 py-1 transition-colors"
:class="showByUser
? 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600 dark:hover:text-gray-300'"
:title="showByUser ? t('admin.ops.concurrency.switchToPlatform') : t('admin.ops.concurrency.switchToUser')"
@click="showByUser = !showByUser"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</button>
<!-- 刷新按钮 -->
<button
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="loadData"
>
<svg class="h-3 w-3" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
<!-- 错误提示 -->
@@ -344,8 +417,41 @@ watch(
{{ t('admin.ops.concurrency.empty') }}
</div>
<!-- 用户视图 -->
<div v-else-if="displayDimension === 'user'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as UserRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
<!-- 用户信息和并发 -->
<div class="mb-1.5 flex items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.username || row.user_email">
{{ row.username || row.user_email }}
</span>
<span v-if="row.username" class="shrink-0 truncate text-[10px] text-gray-400 dark:text-gray-500" :title="row.user_email">
{{ row.user_email }}
</span>
</div>
<div class="flex shrink-0 items-center gap-2 text-[10px]">
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
<span :class="['font-bold', getLoadTextClass(row.load_percentage)]"> {{ Math.round(row.load_percentage) }}% </span>
</div>
</div>
<!-- 进度条 -->
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
</div>
<!-- 等待队列 -->
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
</span>
</div>
</div>
</div>
<!-- 汇总视图平台/分组 -->
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-else-if="displayDimension === 'platform' || displayDimension === 'group'" class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
<!-- 标题行 -->
<div class="mb-2 flex items-center justify-between gap-2">