Files
zsglpt/admin-frontend/src/pages/ReportPage.vue

1219 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
import {
Calendar,
ChatLineSquare,
Clock,
Cpu,
Key,
Loading,
Message,
Star,
Tickets,
Tools,
TrendCharts,
UserFilled,
} from '@element-plus/icons-vue'
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchEmailStats } from '../api/email'
import { fetchDockerStats, fetchRequestMetrics, fetchRunningTasks, fetchServerInfo, fetchSlowSqlMetrics, fetchTaskStats } from '../api/tasks'
import { fetchBrowserPoolStats } from '../api/browser_pool'
import { fetchSystemConfig } from '../api/system'
import MetricGrid from '../components/MetricGrid.vue'
const refreshStats = inject('refreshStats', null)
const adminStats = inject('adminStats', null)
const loading = ref(false)
const refreshing = ref(false)
const lastUpdatedAt = ref('')
const taskStats = ref(null)
const runningTasks = ref(null)
const emailStats = ref(null)
const feedbackStats = ref(null)
const serverInfo = ref(null)
const dockerStats = ref(null)
const browserPoolStats = ref(null)
const systemConfig = ref(null)
const requestMetrics = ref(null)
const slowSqlMetrics = ref(null)
const requestDetailsOpen = ref(false)
const slowSqlDetailsOpen = ref(false)
const queueTab = ref('running')
function recordUpdatedAt() {
try {
lastUpdatedAt.value = new Date().toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' })
} catch {
lastUpdatedAt.value = ''
}
}
function normalizeCount(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function parsePercent(value) {
const text = String(value ?? '').trim()
if (!text) return 0
const cleaned = text.endsWith('%') ? text.slice(0, -1) : text
const n = Number(cleaned)
if (!Number.isFinite(n)) return 0
if (n < 0) return 0
if (n > 1000) return 1000
return n
}
function percentText(value) {
return `${Math.round(parsePercent(value))}%`
}
function formatMs(value) {
const n = Number(value)
if (!Number.isFinite(n) || n < 0) return '-'
if (n >= 100) return `${Math.round(n)}ms`
return `${n.toFixed(1)}ms`
}
function formatUnixTime(value) {
const ts = Number(value)
if (!Number.isFinite(ts) || ts <= 0) return '-'
try {
return new Date(ts * 1000).toLocaleTimeString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' })
} catch {
return '-'
}
}
function formatUnixDateTime(value) {
const ts = Number(value)
if (!Number.isFinite(ts) || ts <= 0) return '-'
try {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' })
} catch {
return '-'
}
}
function sourceLabel(source) {
const raw = String(source ?? '').trim()
if (!raw) return '手动'
if (raw === 'manual') return '手动'
if (raw === 'scheduled') return '系统定时'
if (raw === 'batch') return '批量执行'
if (raw === 'resumed') return '断点续跑'
if (raw.startsWith('user_scheduled:')) return '用户定时'
return raw
}
const overviewCards = computed(() => {
const s = adminStats?.value || {}
const liveMax = normalizeCount(runningTasks.value?.max_concurrent)
return [
{ label: '总用户数', value: normalizeCount(s.total_users), icon: UserFilled, tone: 'blue' },
{ label: '今日注册', value: normalizeCount(s.new_users_today), icon: Calendar, tone: 'green' },
{ label: '近7天注册', value: normalizeCount(s.new_users_7d), icon: TrendCharts, tone: 'purple' },
{ label: '总账号数', value: normalizeCount(s.total_accounts), icon: Key, tone: 'cyan' },
{ label: 'VIP用户', value: normalizeCount(s.vip_users), icon: Star, tone: 'orange' },
{
label: '运行中任务',
value: normalizeCount(runningTasks.value?.running_count),
icon: Loading,
tone: 'green',
sub: liveMax ? `并发上限 ${liveMax}` : '',
},
{ label: '排队任务', value: normalizeCount(runningTasks.value?.queuing_count), icon: Clock, tone: 'purple' },
]
})
const taskToday = computed(() => taskStats.value?.today || {})
const taskTotal = computed(() => taskStats.value?.total || {})
const runningTaskList = computed(() => runningTasks.value?.running || [])
const queuingTaskList = computed(() => runningTasks.value?.queuing || [])
const runningCount = computed(() => normalizeCount(runningTasks.value?.running_count))
const queuingCount = computed(() => normalizeCount(runningTasks.value?.queuing_count))
const browserPoolWorkers = computed(() => {
const workers = browserPoolStats.value?.workers
if (!Array.isArray(workers)) return []
return [...workers].sort((a, b) => normalizeCount(a?.worker_id) - normalizeCount(b?.worker_id))
})
const browserPoolTotalWorkers = computed(() => normalizeCount(browserPoolStats.value?.total_workers))
const browserPoolActiveWorkers = computed(() => browserPoolWorkers.value.filter((w) => Boolean(w?.has_browser)).length)
const browserPoolIdleWorkers = computed(() => normalizeCount(browserPoolStats.value?.idle_workers))
const browserPoolQueueSize = computed(() => normalizeCount(browserPoolStats.value?.queue_size))
const browserPoolBusyWorkers = computed(() => normalizeCount(browserPoolStats.value?.active_workers))
function workerPoolStatusType(worker) {
if (!worker?.thread_alive) return 'danger'
if (worker?.has_browser) return 'success'
return 'info'
}
function workerPoolStatusLabel(worker) {
if (!worker?.thread_alive) return '异常'
if (worker?.has_browser) return '活跃'
return '空闲'
}
function workerRunTagType(worker) {
if (!worker?.thread_alive) return 'danger'
return worker?.idle ? 'info' : 'warning'
}
function workerRunLabel(worker) {
if (!worker?.thread_alive) return '停止'
return worker?.idle ? '空闲' : '忙碌'
}
const taskTodaySuccessRate = computed(() => {
const success = normalizeCount(taskToday.value.success_tasks)
const failed = normalizeCount(taskToday.value.failed_tasks)
const total = success + failed
return total > 0 ? Math.round((success / total) * 1000) / 10 : 0
})
const emailSuccessRate = computed(() => normalizeCount(emailStats.value?.success_rate))
const taskTodayCards = computed(() => [
{ label: '总任务', value: normalizeCount(taskToday.value.total_tasks), tone: 'blue' },
{ label: '成功', value: normalizeCount(taskToday.value.success_tasks), tone: 'green' },
{ label: '失败', value: normalizeCount(taskToday.value.failed_tasks), tone: 'red' },
{ label: '浏览内容', value: normalizeCount(taskToday.value.total_items), tone: 'purple' },
{ label: '查看附件', value: normalizeCount(taskToday.value.total_attachments), tone: 'cyan' },
])
const taskTotalCards = computed(() => [
{ label: '总任务', value: normalizeCount(taskTotal.value.total_tasks), tone: 'blue' },
{ label: '成功', value: normalizeCount(taskTotal.value.success_tasks), tone: 'green' },
{ label: '失败', value: normalizeCount(taskTotal.value.failed_tasks), tone: 'red' },
{ label: '浏览内容', value: normalizeCount(taskTotal.value.total_items), tone: 'purple' },
{ label: '查看附件', value: normalizeCount(taskTotal.value.total_attachments), tone: 'cyan' },
])
const emailCards = computed(() => [
{ label: '总发送', value: normalizeCount(emailStats.value?.total_sent), tone: 'blue' },
{ label: '成功', value: normalizeCount(emailStats.value?.total_success), tone: 'green' },
{ label: '失败', value: normalizeCount(emailStats.value?.total_failed), tone: 'red' },
{ label: '成功率', value: `${emailSuccessRate.value}%`, tone: 'purple' },
])
const emailTypeCards = computed(() => [
{ label: '注册验证', value: normalizeCount(emailStats.value?.register_sent), tone: 'cyan' },
{ label: '密码重置', value: normalizeCount(emailStats.value?.reset_sent), tone: 'orange' },
{ label: '邮箱绑定', value: normalizeCount(emailStats.value?.bind_sent), tone: 'purple' },
{ label: '任务完成', value: normalizeCount(emailStats.value?.task_complete_sent), tone: 'green' },
])
const feedbackCards = computed(() => [
{ label: '总反馈', value: normalizeCount(feedbackStats.value?.total), tone: 'blue' },
{ label: '待处理', value: normalizeCount(feedbackStats.value?.pending), tone: 'orange' },
{ label: '已回复', value: normalizeCount(feedbackStats.value?.replied), tone: 'green' },
])
const browserPoolCards = computed(() => [
{ label: '总 Worker', value: browserPoolTotalWorkers.value, tone: 'blue' },
{ label: '活跃 Worker', value: browserPoolActiveWorkers.value, tone: 'green' },
{ label: '空闲 Worker', value: browserPoolIdleWorkers.value, tone: 'cyan' },
{ label: '忙碌 Worker', value: browserPoolBusyWorkers.value, tone: 'orange' },
{ label: '队列', value: browserPoolQueueSize.value, tone: 'purple' },
])
const scheduleEnabled = computed(() => (systemConfig.value?.schedule_enabled ?? 0) === 1)
const scheduleTime = computed(() => systemConfig.value?.schedule_time || '-')
const scheduleBrowseType = computed(() => systemConfig.value?.schedule_browse_type || '-')
const scheduleWeekdays = computed(() => String(systemConfig.value?.schedule_weekdays || '').trim())
const scheduleWeekdaysDisplay = computed(() => {
const raw = scheduleWeekdays.value
if (!raw) return ''
const map = {
1: '周一',
2: '周二',
3: '周三',
4: '周四',
5: '周五',
6: '周六',
7: '周日',
}
const parts = raw
.split(',')
.map((x) => x.trim())
.filter(Boolean)
if (!parts.length) return raw
return parts.map((p) => map[Number(p)] || p).join('、')
})
const proxyEnabled = computed(() => (systemConfig.value?.proxy_enabled ?? 0) === 1)
const proxyApiUrl = computed(() => systemConfig.value?.proxy_api_url || '')
const proxyExpireMinutes = computed(() => normalizeCount(systemConfig.value?.proxy_expire_minutes))
const maxConcurrentGlobal = computed(() => normalizeCount(systemConfig.value?.max_concurrent_global))
const maxConcurrentPerAccount = computed(() => normalizeCount(systemConfig.value?.max_concurrent_per_account))
const maxScreenshotConcurrent = computed(() => normalizeCount(systemConfig.value?.max_screenshot_concurrent))
const runningCountsLabel = computed(() => {
const runningCount = normalizeCount(runningTasks.value?.running_count)
const queuingCount = normalizeCount(runningTasks.value?.queuing_count)
const maxGlobal = normalizeCount(runningTasks.value?.max_concurrent)
return `运行中 ${runningCount} / 排队 ${queuingCount} / 并发上限 ${maxGlobal || maxConcurrentGlobal.value || '-'}`
})
const overviewModuleItems = computed(() =>
overviewCards.value.map((item) => ({
label: item.label,
value: item.sub ? `${item.value}${item.sub}` : item.value,
})),
)
const taskModuleItems = computed(() => [
{ label: '今日总任务', value: normalizeCount(taskToday.value.total_tasks) },
{ label: '今日成功', value: normalizeCount(taskToday.value.success_tasks) },
{ label: '今日失败', value: normalizeCount(taskToday.value.failed_tasks) },
{ label: '今日成功率', value: `${taskTodaySuccessRate.value}%` },
{ label: '累计任务', value: normalizeCount(taskTotal.value.total_tasks) },
{ label: '累计成功', value: normalizeCount(taskTotal.value.success_tasks) },
])
const queueModuleItems = computed(() => [
{ label: '运行中', value: runningCount.value },
{ label: '排队中', value: queuingCount.value },
{ label: '并发上限', value: normalizeCount(runningTasks.value?.max_concurrent) || maxConcurrentGlobal.value || '-' },
{ label: '排队首条来源', value: sourceLabel(queuingTaskList.value[0]?.source) },
{ label: '排队首条状态', value: queuingTaskList.value[0]?.detail_status || queuingTaskList.value[0]?.status || '-' },
{ label: '最长等待', value: queuingTaskList.value[0]?.elapsed_display || '-' },
])
const emailModuleItems = computed(() => [
{ label: '总发送', value: normalizeCount(emailStats.value?.total_sent) },
{ label: '成功', value: normalizeCount(emailStats.value?.total_success) },
{ label: '失败', value: normalizeCount(emailStats.value?.total_failed) },
{ label: '成功率', value: `${emailSuccessRate.value}%` },
{ label: '注册验证', value: normalizeCount(emailStats.value?.register_sent) },
{ label: '重置密码', value: normalizeCount(emailStats.value?.reset_sent) },
])
const feedbackModuleItems = computed(() => [
{ label: '总反馈', value: normalizeCount(feedbackStats.value?.total) },
{ label: '待处理', value: normalizeCount(feedbackStats.value?.pending) },
{ label: '已回复', value: normalizeCount(feedbackStats.value?.replied) },
])
const resourceModuleItems = computed(() => [
{ label: 'CPU', value: percentText(serverInfo.value?.cpu_percent) },
{ label: '内存', value: percentText(serverInfo.value?.memory_percent) },
{ label: '磁盘', value: percentText(serverInfo.value?.disk_percent) },
{ label: '容器状态', value: dockerStats.value?.status || '-' },
{ label: '容器名', value: dockerStats.value?.container_name || '-' },
{ label: '容器运行', value: dockerStats.value?.uptime || '-' },
])
const workerModuleItems = computed(() => [
{ label: '总 Worker', value: browserPoolTotalWorkers.value },
{ label: '活跃 Worker', value: browserPoolActiveWorkers.value },
{ label: '忙碌 Worker', value: browserPoolBusyWorkers.value },
{ label: '空闲 Worker', value: browserPoolIdleWorkers.value },
{ label: '任务队列', value: browserPoolQueueSize.value },
])
const requestTopPaths = computed(() => {
const rows = requestMetrics.value?.top_paths
if (!Array.isArray(rows)) return []
return rows.slice(0, 3)
})
const requestModuleItems = computed(() => {
const items = [
{ label: '总请求', value: normalizeCount(requestMetrics.value?.total_requests) },
{ label: 'API请求', value: normalizeCount(requestMetrics.value?.api_requests) },
{ label: '慢请求', value: normalizeCount(requestMetrics.value?.slow_requests) },
{ label: '错误请求', value: normalizeCount(requestMetrics.value?.error_requests) },
]
requestTopPaths.value.forEach((row, index) => {
const pathText = String(row?.path || '-')
items.push({
label: `慢接口${index + 1}`,
value: `${pathText} · 峰值 ${formatMs(row?.max_ms)}`,
})
})
return items
})
const requestModuleDesc = computed(() => {
const avgMs = formatMs(requestMetrics.value?.avg_duration_ms)
const maxMs = formatMs(requestMetrics.value?.max_duration_ms)
const lastAt = formatUnixTime(requestMetrics.value?.last_request_ts)
const slowThreshold = formatMs(requestMetrics.value?.slow_threshold_ms)
return `均值 ${avgMs} · 峰值 ${maxMs} · 慢阈 ${slowThreshold} · 最近 ${lastAt}`
})
const slowSqlTopRows = computed(() => {
const rows = Array.isArray(slowSqlMetrics.value?.top_sql) ? slowSqlMetrics.value.top_sql : []
return rows.slice(0, 3)
})
const slowSqlWindowHours = computed(() => {
const seconds = normalizeCount(slowSqlMetrics.value?.window_seconds)
if (seconds <= 0) return 24
return Math.max(1, Math.round(seconds / 3600))
})
const slowSqlModuleItems = computed(() => {
const items = [
{ label: `慢SQL(${slowSqlWindowHours.value}h)`, value: normalizeCount(slowSqlMetrics.value?.total_slow_queries) },
{ label: '去重SQL', value: normalizeCount(slowSqlMetrics.value?.unique_sql) },
{ label: '平均耗时', value: formatMs(slowSqlMetrics.value?.avg_duration_ms) },
{ label: '峰值耗时', value: formatMs(slowSqlMetrics.value?.max_duration_ms) },
]
slowSqlTopRows.value.forEach((row, index) => {
items.push({
label: `慢SQL${index + 1}`,
value: `${formatMs(row?.max_ms)} · ${String(row?.sql || '-')}`,
})
})
return items
})
const slowSqlModuleDesc = computed(() => {
const threshold = formatMs(slowSqlMetrics.value?.slow_threshold_ms)
const lastAt = formatUnixTime(slowSqlMetrics.value?.last_slow_ts)
return `窗口 ${slowSqlWindowHours.value}h · 慢阈 ${threshold} · 最近 ${lastAt}`
})
const slowSqlDetailTopRows = computed(() => {
const rows = Array.isArray(slowSqlMetrics.value?.top_sql) ? slowSqlMetrics.value.top_sql : []
return rows.map((row, index) => ({
rank: index + 1,
sql: String(row?.sql || '-'),
count: normalizeCount(row?.count),
avg_ms: formatMs(row?.avg_ms),
max_ms: formatMs(row?.max_ms),
last_seen: formatUnixDateTime(row?.last_ts),
sample_params: String(row?.sample_params || '-'),
}))
})
const slowSqlRecentRows = computed(() => {
const rows = Array.isArray(slowSqlMetrics.value?.recent_slow_sql) ? slowSqlMetrics.value.recent_slow_sql : []
return [...rows]
.sort((a, b) => Number(b?.time || 0) - Number(a?.time || 0))
.map((row) => ({
time_text: formatUnixDateTime(row?.time),
sql: String(row?.sql || '-'),
duration_ms: formatMs(row?.duration_ms),
params: String(row?.params || '-'),
}))
})
const requestDetailTopPaths = computed(() => {
const rows = Array.isArray(requestMetrics.value?.top_paths) ? requestMetrics.value.top_paths : []
return rows.map((row, index) => ({
rank: index + 1,
path: String(row?.path || '-'),
count: normalizeCount(row?.count),
avg_ms: formatMs(row?.avg_ms),
max_ms: formatMs(row?.max_ms),
status_5xx: normalizeCount(row?.status_5xx),
}))
})
const requestRecentSlowRows = computed(() => {
const rows = Array.isArray(requestMetrics.value?.recent_slow) ? requestMetrics.value.recent_slow : []
return [...rows]
.sort((a, b) => Number(b?.time || 0) - Number(a?.time || 0))
.map((row) => ({
time_text: formatUnixDateTime(row?.time),
method: String(row?.method || '-').toUpperCase(),
path: String(row?.path || '-'),
status: normalizeCount(row?.status),
duration_ms: formatMs(row?.duration_ms),
}))
})
function requestStatusTagType(status) {
const code = normalizeCount(status)
if (code >= 500) return 'danger'
if (code >= 400) return 'warning'
if (code >= 300) return 'info'
return 'success'
}
function openRequestDetails() {
requestDetailsOpen.value = true
}
function openSlowSqlDetails() {
slowSqlDetailsOpen.value = true
}
const reportSlowSqlThreshold = computed(() => {
const runtimeThreshold = slowSqlMetrics.value?.slow_threshold_ms
if (runtimeThreshold !== undefined && runtimeThreshold !== null) {
return formatMs(runtimeThreshold)
}
const configThreshold = systemConfig.value?.db_slow_query_ms
if (configThreshold !== undefined && configThreshold !== null) {
return formatMs(configThreshold)
}
return '-'
})
const configModuleItems = computed(() => [
{ label: '定时任务', value: scheduleEnabled.value ? '启用' : '关闭' },
{ label: '执行时间', value: scheduleTime.value || '-' },
{ label: '浏览类型', value: scheduleBrowseType.value || '-' },
{ label: '代理', value: proxyEnabled.value ? '启用' : '关闭' },
{ label: '代理有效期', value: proxyExpireMinutes.value ? `${proxyExpireMinutes.value} 分钟` : '-' },
{ label: '全局并发', value: maxConcurrentGlobal.value || '-' },
{ label: '单账号并发', value: maxConcurrentPerAccount.value || '-' },
{ label: '截图并发', value: maxScreenshotConcurrent.value || '-' },
{ label: '慢SQL阈值', value: reportSlowSqlThreshold.value },
])
const mobileModules = computed(() => [
{
key: 'overview',
title: '平台概览',
desc: lastUpdatedAt.value ? `更新 ${lastUpdatedAt.value}` : '核心指标',
tone: 'blue',
items: overviewModuleItems.value,
},
{
key: 'task',
title: '任务概览',
desc: normalizeCount(taskToday.value.total_tasks) > 0 ? `今日成功率 ${taskTodaySuccessRate.value}%` : '今日暂无任务',
tone: 'purple',
items: taskModuleItems.value,
},
{
key: 'queue',
title: '队列监控',
desc: runningCountsLabel.value,
tone: 'blue',
items: queueModuleItems.value,
},
{
key: 'email',
title: '邮件报表',
desc: `成功率 ${emailSuccessRate.value}%`,
tone: 'cyan',
items: emailModuleItems.value,
},
{
key: 'feedback',
title: '反馈概览',
desc: `待处理 ${normalizeCount(feedbackStats.value?.pending)}`,
tone: 'orange',
items: feedbackModuleItems.value,
},
{
key: 'resource',
title: '系统资源',
desc: serverInfo.value?.uptime ? `运行 ${serverInfo.value.uptime}` : '运行状态获取中',
tone: 'green',
items: resourceModuleItems.value,
},
{
key: 'request',
title: '接口性能',
desc: requestModuleDesc.value,
tone: 'purple',
items: requestModuleItems.value,
},
{
key: 'slow_sql',
title: '慢SQL监控',
desc: slowSqlModuleDesc.value,
tone: 'red',
items: slowSqlModuleItems.value,
},
{
key: 'worker',
title: '截图线程池',
desc: `活跃 ${browserPoolActiveWorkers.value} · 忙碌 ${browserPoolBusyWorkers.value}`,
tone: 'cyan',
items: workerModuleItems.value,
},
{
key: 'config',
title: '配置概览',
desc: '并发 / 代理 / 定时任务',
tone: 'red',
items: configModuleItems.value,
},
])
const REPORT_SYNC_STATS_EVERY = 3
let reportRefreshTick = 1
async function refreshAll(options = {}) {
const showLoading = options.showLoading ?? true
if (refreshing.value) return
const forceStatsSync = Boolean(options.forceStatsSync)
const shouldSyncStats = forceStatsSync || reportRefreshTick % REPORT_SYNC_STATS_EVERY === 0
reportRefreshTick += 1
refreshing.value = true
if (showLoading) {
loading.value = true
}
try {
const [
taskResult,
runningResult,
emailResult,
feedbackResult,
serverResult,
dockerResult,
browserPoolResult,
requestMetricsResult,
slowSqlResult,
configResult,
] = await Promise.allSettled([
fetchTaskStats(),
fetchRunningTasks(),
fetchEmailStats(),
fetchFeedbackStats(),
fetchServerInfo(),
fetchDockerStats(),
fetchBrowserPoolStats(),
fetchRequestMetrics(),
fetchSlowSqlMetrics(),
fetchSystemConfig(),
])
if (taskResult.status === 'fulfilled') taskStats.value = taskResult.value
if (runningResult.status === 'fulfilled') runningTasks.value = runningResult.value
if (emailResult.status === 'fulfilled') emailStats.value = emailResult.value
if (feedbackResult.status === 'fulfilled') feedbackStats.value = feedbackResult.value
if (serverResult.status === 'fulfilled') serverInfo.value = serverResult.value
if (dockerResult.status === 'fulfilled') dockerStats.value = dockerResult.value
if (browserPoolResult.status === 'fulfilled') browserPoolStats.value = browserPoolResult.value
if (requestMetricsResult.status === 'fulfilled') requestMetrics.value = requestMetricsResult.value
if (slowSqlResult.status === 'fulfilled') slowSqlMetrics.value = slowSqlResult.value
if (configResult.status === 'fulfilled') systemConfig.value = configResult.value
if (shouldSyncStats) {
await refreshStats?.({ force: forceStatsSync })
}
recordUpdatedAt()
} finally {
refreshing.value = false
if (showLoading) {
loading.value = false
}
}
}
const REPORT_POLL_ACTIVE_MS = 5000
const REPORT_POLL_HIDDEN_MS = 20000
let refreshTimer = null
function isPageHidden() {
if (typeof document === 'undefined') return false
return document.visibilityState === 'hidden'
}
function currentRefreshDelay() {
return isPageHidden() ? REPORT_POLL_HIDDEN_MS : REPORT_POLL_ACTIVE_MS
}
function stopRefreshLoop() {
if (!refreshTimer) return
clearTimeout(refreshTimer)
refreshTimer = null
}
function scheduleRefreshLoop() {
stopRefreshLoop()
refreshTimer = window.setTimeout(async () => {
refreshTimer = null
await refreshAll({ showLoading: false }).catch(() => {})
scheduleRefreshLoop()
}, currentRefreshDelay())
}
function onVisibilityChange() {
scheduleRefreshLoop()
}
onMounted(() => {
refreshAll({ showLoading: false })
.catch(() => {})
.finally(() => {
scheduleRefreshLoop()
})
window.addEventListener('visibilitychange', onVisibilityChange)
})
onUnmounted(() => {
stopRefreshLoop()
window.removeEventListener('visibilitychange', onVisibilityChange)
})
</script>
<template>
<div class="page-stack">
<section class="report-hero">
<div class="hero-head">
<div class="hero-main">
<h2>报表中心</h2>
<div class="hero-meta app-muted">
<span v-if="lastUpdatedAt">更新时间{{ lastUpdatedAt }}</span>
<span class="hero-dot">·</span>
<span>慢SQL阈值 {{ reportSlowSqlThreshold }}</span>
<span v-if="serverInfo?.uptime" class="hero-dot">·</span>
<span v-if="serverInfo?.uptime">运行 {{ serverInfo.uptime }}</span>
</div>
</div>
</div>
<MetricGrid class="hero-overview-grid" :items="overviewCards" :loading="loading" :min-width="165" />
</section>
<section class="mobile-report">
<el-card
v-for="module in mobileModules"
:key="module.key"
shadow="never"
class="mobile-module-card"
:class="`mobile-tone-${module.tone}`"
:body-style="{ padding: '12px' }"
>
<div class="mobile-module-head">
<div class="mobile-module-title">{{ module.title }}</div>
<div class="mobile-module-desc app-muted">{{ module.desc }}</div>
</div>
<div class="mobile-metrics">
<div v-for="item in module.items" :key="`${module.key}-${item.label}`" class="mobile-metric-item">
<div class="mobile-metric-label app-muted">{{ item.label }}</div>
<div class="mobile-metric-value">{{ item.value }}</div>
</div>
</div>
<div v-if="module.key === 'request' || module.key === 'slow_sql'" class="module-extra-actions">
<el-button
size="small"
type="primary"
plain
@click="module.key === 'request' ? openRequestDetails() : openSlowSqlDetails()"
>
{{ module.key === 'request' ? '查看慢接口详情' : '查看慢SQL详情' }}
</el-button>
</div>
</el-card>
</section>
<el-dialog v-model="requestDetailsOpen" title="慢接口详情" width="min(1080px, 96vw)">
<div class="request-dialog-summary app-muted">
<span>总请求{{ normalizeCount(requestMetrics?.total_requests) }}</span>
<span>API请求{{ normalizeCount(requestMetrics?.api_requests) }}</span>
<span>慢请求{{ normalizeCount(requestMetrics?.slow_requests) }}</span>
<span>错误请求{{ normalizeCount(requestMetrics?.error_requests) }}</span>
</div>
<div class="request-dialog-block">
<div class="request-dialog-title">慢接口排行榜</div>
<div class="table-wrap">
<el-table :data="requestDetailTopPaths" size="small" max-height="280">
<el-table-column prop="rank" label="#" width="60" />
<el-table-column prop="path" label="接口路径" min-width="340" show-overflow-tooltip />
<el-table-column prop="count" label="请求数" width="100" />
<el-table-column prop="avg_ms" label="平均耗时" width="120" />
<el-table-column prop="max_ms" label="峰值耗时" width="120" />
<el-table-column prop="status_5xx" label="5xx" width="90" />
</el-table>
</div>
</div>
<div class="request-dialog-block">
<div class="request-dialog-title">最近慢请求</div>
<div class="table-wrap">
<el-table :data="requestRecentSlowRows" size="small" max-height="320">
<el-table-column prop="time_text" label="时间" width="180" />
<el-table-column prop="method" label="方法" width="90" />
<el-table-column prop="path" label="接口路径" min-width="320" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="scope">
<el-tag size="small" :type="requestStatusTagType(scope.row.status)">{{ scope.row.status || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="duration_ms" label="耗时" width="110" />
</el-table>
</div>
</div>
</el-dialog>
<el-dialog v-model="slowSqlDetailsOpen" title="慢SQL详情近24小时" width="min(1080px, 96vw)">
<div class="request-dialog-summary app-muted">
<span>慢SQL总数{{ normalizeCount(slowSqlMetrics?.total_slow_queries) }}</span>
<span>去重SQL{{ normalizeCount(slowSqlMetrics?.unique_sql) }}</span>
<span>平均耗时{{ formatMs(slowSqlMetrics?.avg_duration_ms) }}</span>
<span>峰值耗时{{ formatMs(slowSqlMetrics?.max_duration_ms) }}</span>
<span>慢阈值{{ formatMs(slowSqlMetrics?.slow_threshold_ms) }}</span>
</div>
<div class="request-dialog-block">
<div class="request-dialog-title">TOP 慢SQL按出现次数</div>
<div class="table-wrap">
<el-table :data="slowSqlDetailTopRows" size="small" max-height="320">
<el-table-column prop="rank" label="#" width="60" />
<el-table-column prop="sql" label="SQL" min-width="400" show-overflow-tooltip />
<el-table-column prop="count" label="次数" width="90" />
<el-table-column prop="avg_ms" label="平均耗时" width="120" />
<el-table-column prop="max_ms" label="峰值耗时" width="120" />
<el-table-column prop="last_seen" label="最近出现" width="180" />
<el-table-column prop="sample_params" label="参数样本" min-width="140" show-overflow-tooltip />
</el-table>
</div>
</div>
<div class="request-dialog-block">
<div class="request-dialog-title">最近慢SQL</div>
<div class="table-wrap">
<el-table :data="slowSqlRecentRows" size="small" max-height="320">
<el-table-column prop="time_text" label="时间" width="180" />
<el-table-column prop="sql" label="SQL" min-width="420" show-overflow-tooltip />
<el-table-column prop="duration_ms" label="耗时" width="110" />
<el-table-column prop="params" label="参数" min-width="130" show-overflow-tooltip />
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.report-hero {
position: relative;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: radial-gradient(circle at 10% 10%, rgba(59, 130, 246, 0.18), transparent 48%),
radial-gradient(circle at 80% 0%, rgba(236, 72, 153, 0.16), transparent 45%),
radial-gradient(circle at 90% 90%, rgba(16, 185, 129, 0.14), transparent 42%),
rgba(255, 255, 255, 0.72);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.08);
backdrop-filter: saturate(180%) blur(12px);
padding: 16px;
}
.hero-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.hero-main h2 {
margin: 0;
font-size: 19px;
font-weight: 900;
letter-spacing: 0.2px;
}
.hero-meta {
margin-top: 6px;
font-size: 12px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.hero-dot {
opacity: 0.65;
}
.hero-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.mobile-report {
display: grid;
gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mobile-module-card {
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(17, 24, 39, 0.12);
background: rgba(255, 255, 255, 0.9);
box-shadow: var(--app-shadow-soft);
}
.mobile-module-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
height: 3px;
background: var(--mobile-accent, #3b82f6);
}
.mobile-tone-blue {
--mobile-accent: linear-gradient(90deg, #3b82f6, #06b6d4);
}
.mobile-tone-cyan {
--mobile-accent: linear-gradient(90deg, #06b6d4, #3b82f6);
}
.mobile-tone-purple {
--mobile-accent: linear-gradient(90deg, #8b5cf6, #ec4899);
}
.mobile-tone-orange {
--mobile-accent: linear-gradient(90deg, #f59e0b, #f97316);
}
.mobile-tone-green {
--mobile-accent: linear-gradient(90deg, #10b981, #22c55e);
}
.mobile-tone-red {
--mobile-accent: linear-gradient(90deg, #ef4444, #f43f5e);
}
.mobile-module-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.mobile-module-title {
font-size: 13px;
font-weight: 900;
color: #0f172a;
}
.mobile-module-desc {
min-width: 0;
max-width: 68%;
font-size: 11px;
line-height: 1.4;
text-align: right;
}
.mobile-metrics {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.mobile-metric-item {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(17, 24, 39, 0.08);
background: rgba(248, 250, 252, 0.9);
}
.mobile-metric-label {
font-size: 11px;
line-height: 1.35;
}
.mobile-metric-value {
margin-top: 4px;
font-size: 14px;
font-weight: 800;
color: #0f172a;
line-height: 1.3;
word-break: break-word;
}
.module-extra-actions {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.request-dialog-summary {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
font-size: 12px;
margin-bottom: 12px;
}
.request-dialog-block + .request-dialog-block {
margin-top: 14px;
}
.request-dialog-title {
font-size: 13px;
font-weight: 800;
margin-bottom: 8px;
}
.hero-overview-grid {
display: none;
}
.panel {
border-radius: 18px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: rgba(255, 255, 255, 0.72);
box-shadow: var(--app-shadow);
backdrop-filter: saturate(180%) blur(10px);
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.head-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.head-text {
min-width: 0;
}
.head-icon {
width: 40px;
height: 40px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(17, 24, 39, 0.08);
flex: 0 0 auto;
}
.tone-blue {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.tone-cyan {
background: rgba(34, 211, 238, 0.12);
color: #0369a1;
}
.tone-purple {
background: rgba(139, 92, 246, 0.12);
color: #6d28d9;
}
.tone-orange {
background: rgba(245, 158, 11, 0.12);
color: #b45309;
}
.tone-green {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.tone-red {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
}
.panel-title {
font-size: 14px;
font-weight: 900;
}
.panel-sub {
margin-top: 4px;
font-size: 12px;
color: var(--app-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metrics-block {
display: flex;
flex-direction: column;
gap: 10px;
}
.block-title {
font-size: 13px;
font-weight: 900;
letter-spacing: 0.2px;
}
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, rgba(17, 24, 39, 0.12), transparent);
margin: 14px 0;
}
.queue-tabs :deep(.el-tabs__header) {
margin: 0 0 10px;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.table-wrap {
overflow-x: auto;
border-radius: 10px;
border: 1px solid var(--app-border);
background: #fff;
}
.help {
margin-top: 10px;
font-size: 12px;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.resource-item {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.7);
}
.resource-k {
font-size: 12px;
margin-bottom: 8px;
}
.resource-sub {
margin-top: 8px;
font-size: 12px;
}
.config-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.config-item {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.7);
}
.config-k {
font-size: 12px;
}
.config-v {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.config-inline {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-split {
opacity: 0.65;
}
.config-sub {
margin-top: 8px;
font-size: 12px;
}
.err {
color: #b91c1c;
}
:deep(.el-table) {
--el-table-border-color: rgba(17, 24, 39, 0.08);
--el-table-header-bg-color: rgba(246, 247, 251, 0.8);
}
:deep(.el-table th.el-table__cell) {
background: rgba(246, 247, 251, 0.8);
}
@media (max-width: 1200px) {
.mobile-report {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.mobile-report {
grid-template-columns: 1fr;
gap: 10px;
}
.report-hero {
border-radius: 14px;
padding: 12px;
}
.hero-main h2 {
font-size: 17px;
}
.hero-meta {
margin-top: 4px;
gap: 6px;
font-size: 11px;
}
.resource-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 420px) {
.mobile-metrics {
grid-template-columns: 1fr;
}
.mobile-module-desc {
max-width: 62%;
}
}
</style>