526 lines
21 KiB
Vue
526 lines
21 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useIntervalFn } from '@vueuse/core'
|
||
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
|
||
|
||
interface Props {
|
||
platformFilter?: string
|
||
groupIdFilter?: number | null
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
platformFilter: '',
|
||
groupIdFilter: null
|
||
})
|
||
|
||
const { t } = useI18n()
|
||
|
||
const loading = ref(false)
|
||
const errorMessage = ref('')
|
||
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
|
||
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
|
||
|
||
const realtimeEnabled = computed(() => {
|
||
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
|
||
})
|
||
|
||
function safeNumber(n: unknown): number {
|
||
return typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||
}
|
||
|
||
// 计算显示维度
|
||
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
|
||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||
return 'account'
|
||
}
|
||
if (props.platformFilter) {
|
||
return 'group'
|
||
}
|
||
return 'platform'
|
||
})
|
||
|
||
// 平台/分组汇总行数据
|
||
interface SummaryRow {
|
||
key: string
|
||
name: string
|
||
platform?: string
|
||
// 账号统计
|
||
total_accounts: number
|
||
available_accounts: number
|
||
rate_limited_accounts: number
|
||
error_accounts: number
|
||
// 并发统计
|
||
total_concurrency: number
|
||
used_concurrency: number
|
||
waiting_in_queue: number
|
||
// 计算字段
|
||
availability_percentage: number
|
||
concurrency_percentage: number
|
||
}
|
||
|
||
// 账号详细行数据
|
||
interface AccountRow {
|
||
key: string
|
||
name: string
|
||
platform: string
|
||
group_name: string
|
||
// 并发
|
||
current_in_use: number
|
||
max_capacity: number
|
||
waiting_in_queue: number
|
||
load_percentage: number
|
||
// 状态
|
||
is_available: boolean
|
||
is_rate_limited: boolean
|
||
rate_limit_remaining_sec?: number
|
||
is_overloaded: boolean
|
||
overload_remaining_sec?: number
|
||
has_error: boolean
|
||
error_message?: string
|
||
}
|
||
|
||
// 平台维度汇总
|
||
const platformRows = computed((): SummaryRow[] => {
|
||
const concStats = concurrency.value?.platform || {}
|
||
const availStats = availability.value?.platform || {}
|
||
|
||
const platforms = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||
|
||
return Array.from(platforms).map(platform => {
|
||
const conc = concStats[platform] || {}
|
||
const avail = availStats[platform] || {}
|
||
|
||
const totalAccounts = safeNumber(avail.total_accounts)
|
||
const availableAccounts = safeNumber(avail.available_count)
|
||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||
|
||
return {
|
||
key: platform,
|
||
name: platform.toUpperCase(),
|
||
total_accounts: totalAccounts,
|
||
available_accounts: availableAccounts,
|
||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||
error_accounts: safeNumber(avail.error_count),
|
||
total_concurrency: totalConcurrency,
|
||
used_concurrency: usedConcurrency,
|
||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||
}
|
||
}).sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||
})
|
||
|
||
// 分组维度汇总
|
||
const groupRows = computed((): SummaryRow[] => {
|
||
const concStats = concurrency.value?.group || {}
|
||
const availStats = availability.value?.group || {}
|
||
|
||
const groupIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||
|
||
const rows = Array.from(groupIds)
|
||
.map(gid => {
|
||
const conc = concStats[gid] || {}
|
||
const avail = availStats[gid] || {}
|
||
|
||
// 只显示匹配的平台
|
||
if (props.platformFilter && conc.platform !== props.platformFilter && avail.platform !== props.platformFilter) {
|
||
return null
|
||
}
|
||
|
||
const totalAccounts = safeNumber(avail.total_accounts)
|
||
const availableAccounts = safeNumber(avail.available_count)
|
||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||
|
||
return {
|
||
key: gid,
|
||
name: String(conc.group_name || avail.group_name || `Group ${gid}`),
|
||
platform: String(conc.platform || avail.platform || ''),
|
||
total_accounts: totalAccounts,
|
||
available_accounts: availableAccounts,
|
||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||
error_accounts: safeNumber(avail.error_count),
|
||
total_concurrency: totalConcurrency,
|
||
used_concurrency: usedConcurrency,
|
||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||
}
|
||
})
|
||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||
|
||
return rows.sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||
})
|
||
|
||
// 账号维度详细
|
||
const accountRows = computed((): AccountRow[] => {
|
||
const concStats = concurrency.value?.account || {}
|
||
const availStats = availability.value?.account || {}
|
||
|
||
const accountIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||
|
||
const rows = Array.from(accountIds)
|
||
.map(aid => {
|
||
const conc = concStats[aid] || {}
|
||
const avail = availStats[aid] || {}
|
||
|
||
// 只显示匹配的分组
|
||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||
if (conc.group_id !== props.groupIdFilter && avail.group_id !== props.groupIdFilter) {
|
||
return null
|
||
}
|
||
}
|
||
|
||
return {
|
||
key: aid,
|
||
name: String(conc.account_name || avail.account_name || `Account ${aid}`),
|
||
platform: String(conc.platform || avail.platform || ''),
|
||
group_name: String(conc.group_name || avail.group_name || ''),
|
||
current_in_use: safeNumber(conc.current_in_use),
|
||
max_capacity: safeNumber(conc.max_capacity),
|
||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||
load_percentage: safeNumber(conc.load_percentage),
|
||
is_available: avail.is_available || false,
|
||
is_rate_limited: avail.is_rate_limited || false,
|
||
rate_limit_remaining_sec: avail.rate_limit_remaining_sec,
|
||
is_overloaded: avail.is_overloaded || false,
|
||
overload_remaining_sec: avail.overload_remaining_sec,
|
||
has_error: avail.has_error || false,
|
||
error_message: avail.error_message || ''
|
||
}
|
||
})
|
||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||
|
||
return rows.sort((a, b) => {
|
||
// 优先显示异常账号
|
||
if (a.has_error !== b.has_error) return a.has_error ? -1 : 1
|
||
if (a.is_rate_limited !== b.is_rate_limited) return a.is_rate_limited ? -1 : 1
|
||
// 然后按负载排序
|
||
return b.load_percentage - a.load_percentage
|
||
})
|
||
})
|
||
|
||
// 根据维度选择数据
|
||
const displayRows = computed(() => {
|
||
if (displayDimension.value === 'account') return accountRows.value
|
||
if (displayDimension.value === 'group') return groupRows.value
|
||
return platformRows.value
|
||
})
|
||
|
||
const displayTitle = computed(() => {
|
||
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')
|
||
})
|
||
|
||
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
|
||
} catch (err: any) {
|
||
console.error('[OpsConcurrencyCard] Failed to load data', err)
|
||
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 定期刷新(5秒)
|
||
const { pause: pauseRefresh, resume: resumeRefresh } = useIntervalFn(
|
||
() => {
|
||
if (realtimeEnabled.value) {
|
||
loadData()
|
||
}
|
||
},
|
||
5000,
|
||
{ immediate: false }
|
||
)
|
||
|
||
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'
|
||
if (loadPct >= 50) return 'bg-yellow-500 dark:bg-yellow-600'
|
||
return 'bg-green-500 dark:bg-green-600'
|
||
}
|
||
|
||
function getLoadBarStyle(loadPct: number): string {
|
||
return `width: ${Math.min(100, Math.max(0, loadPct))}%`
|
||
}
|
||
|
||
function getLoadTextClass(loadPct: number): string {
|
||
if (loadPct >= 90) return 'text-red-600 dark:text-red-400'
|
||
if (loadPct >= 70) return 'text-orange-600 dark:text-orange-400'
|
||
if (loadPct >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||
return 'text-green-600 dark:text-green-400'
|
||
}
|
||
|
||
function formatDuration(seconds: number): string {
|
||
if (seconds <= 0) return '0s'
|
||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||
const minutes = Math.floor(seconds / 60)
|
||
if (minutes < 60) return `${minutes}m`
|
||
const hours = Math.floor(minutes / 60)
|
||
return `${hours}h`
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
resumeRefresh()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
pauseRefresh()
|
||
})
|
||
|
||
watch(realtimeEnabled, async (enabled) => {
|
||
if (!enabled) {
|
||
pauseRefresh()
|
||
} else {
|
||
resumeRefresh()
|
||
await loadData()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||
<!-- 头部 -->
|
||
<div class="mb-4 flex shrink-0 items-center justify-between gap-3">
|
||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||
</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>
|
||
|
||
<!-- 错误提示 -->
|
||
<div v-if="errorMessage" class="mb-3 shrink-0 rounded-xl bg-red-50 p-2.5 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||
{{ errorMessage }}
|
||
</div>
|
||
|
||
<!-- 禁用状态 -->
|
||
<div
|
||
v-if="!realtimeEnabled"
|
||
class="flex flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
|
||
>
|
||
{{ t('admin.ops.concurrency.disabledHint') }}
|
||
</div>
|
||
|
||
<!-- 数据展示区域 -->
|
||
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||
<!-- 维度标题栏 -->
|
||
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||
{{ displayTitle }}
|
||
</span>
|
||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.ops.concurrency.totalRows', { count: displayRows.length }) }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-if="displayRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||
{{ t('admin.ops.concurrency.empty') }}
|
||
</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-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">
|
||
<div class="flex items-center gap-2">
|
||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||
{{ row.name }}
|
||
</div>
|
||
<span v-if="displayDimension === 'group' && row.platform" class="text-[10px] text-gray-400 dark:text-gray-500">
|
||
{{ row.platform.toUpperCase() }}
|
||
</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.used_concurrency }}/{{ row.total_concurrency }} </span>
|
||
<span :class="['font-bold', getLoadTextClass(row.concurrency_percentage)]"> {{ row.concurrency_percentage }}% </span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 进度条 -->
|
||
<div class="mb-2 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.concurrency_percentage)"
|
||
:style="getLoadBarStyle(row.concurrency_percentage)"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- 统计信息 -->
|
||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]">
|
||
<!-- 账号统计 -->
|
||
<div class="flex items-center gap-1">
|
||
<svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||
/>
|
||
</svg>
|
||
<span class="text-gray-600 dark:text-gray-300">
|
||
<span class="font-bold text-green-600 dark:text-green-400">{{ row.available_accounts }}</span
|
||
>/{{ row.total_accounts }}
|
||
</span>
|
||
<span class="text-gray-400 dark:text-gray-500">{{ row.availability_percentage }}%</span>
|
||
</div>
|
||
|
||
<!-- 限流账号 -->
|
||
<span
|
||
v-if="row.rate_limited_accounts > 0"
|
||
class="rounded-full bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||
>
|
||
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
|
||
</span>
|
||
|
||
<!-- 异常账号 -->
|
||
<span
|
||
v-if="row.error_accounts > 0"
|
||
class="rounded-full bg-red-100 px-1.5 py-0.5 font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
{{ t('admin.ops.concurrency.errorAccounts', { count: row.error_accounts }) }}
|
||
</span>
|
||
|
||
<!-- 等待队列 -->
|
||
<span
|
||
v-if="row.waiting_in_queue > 0"
|
||
class="rounded-full bg-purple-100 px-1.5 py-0.5 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 class="custom-scrollbar max-h-[360px] flex-1 space-y-2 overflow-y-auto p-3">
|
||
<div v-for="row in (displayRows as AccountRow[])" :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="min-w-0 flex-1">
|
||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||
{{ row.name }}
|
||
</div>
|
||
<div class="mt-0.5 text-[9px] text-gray-400 dark:text-gray-500">
|
||
{{ row.group_name }}
|
||
</div>
|
||
</div>
|
||
<div class="flex shrink-0 items-center gap-2">
|
||
<!-- 并发使用 -->
|
||
<span class="font-mono text-[11px] font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
|
||
<!-- 状态徽章 -->
|
||
<span
|
||
v-if="row.is_available"
|
||
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||
>
|
||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
{{ t('admin.ops.accountAvailability.available') }}
|
||
</span>
|
||
<span
|
||
v-else-if="row.is_rate_limited"
|
||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||
>
|
||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
{{ formatDuration(row.rate_limit_remaining_sec || 0) }}
|
||
</span>
|
||
<span
|
||
v-else-if="row.is_overloaded"
|
||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||
/>
|
||
</svg>
|
||
{{ formatDuration(row.overload_remaining_sec || 0) }}
|
||
</span>
|
||
<span
|
||
v-else-if="row.has_error"
|
||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||
>
|
||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
{{ t('admin.ops.accountAvailability.accountError') }}
|
||
</span>
|
||
<span
|
||
v-else
|
||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||
>
|
||
{{ t('admin.ops.accountAvailability.unavailable') }}
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.custom-scrollbar {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background-color: rgba(156, 163, 175, 0.3);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||
background-color: rgba(156, 163, 175, 0.5);
|
||
}
|
||
</style>
|