feat(前端UI): 实现运维监控前端界面

- 新增帮助提示组件(HelpTooltip.vue)
- 更新侧边栏添加 ops 监控菜单项
- 扩展设置视图集成 ops 配置面板
- 新增 ops 监控视图目录(dashboard, alerts, realtime, settings 等)
This commit is contained in:
IanShaw027
2026-01-09 21:00:04 +08:00
parent fc32b57798
commit 8ae75e7f6e
21 changed files with 5362 additions and 6 deletions

View File

@@ -0,0 +1,525 @@
<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 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 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>