1219 lines
37 KiB
Vue
1219 lines
37 KiB
Vue
<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>
|