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

1206 lines
38 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, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
import { fetchBrowserPoolStats } from '../api/browser_pool'
import { fetchSystemConfig } from '../api/system'
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 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 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 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 || '-'}`
})
async function refreshAll(options = {}) {
const showLoading = options.showLoading ?? true
if (refreshing.value) return
refreshing.value = true
if (showLoading) {
loading.value = true
}
try {
const [
taskResult,
runningResult,
emailResult,
feedbackResult,
serverResult,
dockerResult,
browserPoolResult,
configResult,
] = await Promise.allSettled([
fetchTaskStats(),
fetchRunningTasks(),
fetchEmailStats(),
fetchFeedbackStats(),
fetchServerInfo(),
fetchDockerStats(),
fetchBrowserPoolStats(),
fetchSystemConfig(),
])
taskStats.value = taskResult.status === 'fulfilled' ? taskResult.value : null
runningTasks.value = runningResult.status === 'fulfilled' ? runningResult.value : null
emailStats.value = emailResult.status === 'fulfilled' ? emailResult.value : null
feedbackStats.value = feedbackResult.status === 'fulfilled' ? feedbackResult.value : null
serverInfo.value = serverResult.status === 'fulfilled' ? serverResult.value : null
dockerStats.value = dockerResult.status === 'fulfilled' ? dockerResult.value : null
browserPoolStats.value = browserPoolResult.status === 'fulfilled' ? browserPoolResult.value : null
systemConfig.value = configResult.status === 'fulfilled' ? configResult.value : null
await refreshStats?.()
recordUpdatedAt()
} finally {
refreshing.value = false
if (showLoading) {
loading.value = false
}
}
}
let refreshTimer = null
function manualRefresh() {
return refreshAll({ showLoading: true })
}
onMounted(() => {
refreshAll({ showLoading: false })
refreshTimer = setInterval(() => refreshAll({ showLoading: false }), 1000)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<template>
<div class="page-stack">
<div class="hero">
<div class="hero-top">
<div class="hero-title">
<div class="hero-title-row">
<h2>报表中心</h2>
</div>
<div class="hero-meta app-muted">
<span v-if="lastUpdatedAt">更新时间{{ lastUpdatedAt }}</span>
<span v-if="serverInfo?.uptime" class="hero-dot">·</span>
<span v-if="serverInfo?.uptime">运行 {{ serverInfo.uptime }}</span>
</div>
</div>
<div class="hero-actions">
<el-button type="primary" plain :loading="loading" @click="manualRefresh">刷新</el-button>
</div>
</div>
<div class="kpi-grid">
<div v-for="c in overviewCards" :key="c.label" class="kpi-card" :class="`kpi-tone--${c.tone}`">
<div class="kpi-icon">
<el-icon><component :is="c.icon" /></el-icon>
</div>
<div class="kpi-body">
<div class="kpi-value">{{ c.value }}</div>
<div class="kpi-label">{{ c.label }}</div>
<div v-if="c.sub" class="kpi-sub app-muted">{{ c.sub }}</div>
</div>
</div>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-purple">
<el-icon><TrendCharts /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">任务概览</div>
<div class="panel-sub app-muted">
<template v-if="normalizeCount(taskToday.total_tasks) > 0">
今日成功率 {{ taskTodaySuccessRate }}% · {{ runningCountsLabel }}
</template>
<template v-else>今日无任务 · {{ runningCountsLabel }}</template>
</div>
</div>
</div>
<el-progress
type="circle"
:percentage="normalizeCount(taskToday.total_tasks) > 0 ? Math.round(taskTodaySuccessRate) : 0"
:width="74"
:stroke-width="10"
:status="normalizeCount(taskToday.total_tasks) === 0 ? 'success' : taskTodaySuccessRate >= 90 ? 'success' : taskTodaySuccessRate >= 60 ? 'warning' : 'exception'"
/>
</div>
<div class="tile-section">
<div class="tile-title app-muted">今日</div>
<div class="tile-grid">
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskToday.total_tasks) }}</div>
<div class="tile-k app-muted">总任务</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ normalizeCount(taskToday.success_tasks) }}</div>
<div class="tile-k app-muted">成功</div>
</div>
<div class="tile">
<div class="tile-v err">{{ normalizeCount(taskToday.failed_tasks) }}</div>
<div class="tile-k app-muted">失败</div>
</div>
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskToday.total_items) }}</div>
<div class="tile-k app-muted">浏览内容</div>
</div>
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskToday.total_attachments) }}</div>
<div class="tile-k app-muted">查看附件</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="tile-section">
<div class="tile-title app-muted">累计</div>
<div class="tile-grid">
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskTotal.total_tasks) }}</div>
<div class="tile-k app-muted">总任务</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ normalizeCount(taskTotal.success_tasks) }}</div>
<div class="tile-k app-muted">成功</div>
</div>
<div class="tile">
<div class="tile-v err">{{ normalizeCount(taskTotal.failed_tasks) }}</div>
<div class="tile-k app-muted">失败</div>
</div>
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskTotal.total_items) }}</div>
<div class="tile-k app-muted">浏览内容</div>
</div>
<div class="tile">
<div class="tile-v">{{ normalizeCount(taskTotal.total_attachments) }}</div>
<div class="tile-k app-muted">查看附件</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-blue">
<el-icon><Tickets /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">队列监控</div>
<div class="panel-sub app-muted">{{ runningCountsLabel }}</div>
</div>
</div>
</div>
<el-tabs v-model="queueTab" class="queue-tabs" stretch>
<el-tab-pane name="running">
<template #label>
<span class="tab-label">
运行中
<el-tag size="small" effect="light" type="success">{{ runningCount }}</el-tag>
</span>
</template>
<div class="table-wrap">
<el-table :data="runningTaskList.slice(0, 10)" size="small" style="width: 100%">
<el-table-column label="用户" min-width="120">
<template #default="{ row }">{{ row.user_username || '-' }}</template>
</el-table-column>
<el-table-column label="账号" min-width="150">
<template #default="{ row }">{{ row.username || '-' }}</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">{{ sourceLabel(row.source) }}</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.browse_type || '-' }}</template>
</el-table-column>
<el-table-column label="进度" width="100">
<template #default="{ row }">{{ row.progress_items }}/{{ row.progress_attachments }}</template>
</el-table-column>
<el-table-column label="耗时" width="100">
<template #default="{ row }">{{ row.elapsed_display || '-' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="140">
<template #default="{ row }">{{ row.detail_status || row.status || '-' }}</template>
</el-table-column>
</el-table>
</div>
<div v-if="runningCount === 0" class="help app-muted">当前无运行任务</div>
</el-tab-pane>
<el-tab-pane name="queuing">
<template #label>
<span class="tab-label">
排队中
<el-tag size="small" effect="light" type="warning">{{ queuingCount }}</el-tag>
</span>
</template>
<div class="table-wrap">
<el-table :data="queuingTaskList.slice(0, 10)" size="small" style="width: 100%">
<el-table-column label="用户" min-width="120">
<template #default="{ row }">{{ row.user_username || '-' }}</template>
</el-table-column>
<el-table-column label="账号" min-width="150">
<template #default="{ row }">{{ row.username || '-' }}</template>
</el-table-column>
<el-table-column label="来源" width="100">
<template #default="{ row }">{{ sourceLabel(row.source) }}</template>
</el-table-column>
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ row.browse_type || '-' }}</template>
</el-table-column>
<el-table-column label="等待" width="100">
<template #default="{ row }">{{ row.elapsed_display || '-' }}</template>
</el-table-column>
<el-table-column label="状态" min-width="160">
<template #default="{ row }">{{ row.detail_status || row.status || '-' }}</template>
</el-table-column>
</el-table>
</div>
<div v-if="queuingCount === 0" class="help app-muted">当前无排队任务</div>
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-cyan">
<el-icon><Message /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">邮件报表</div>
<div class="panel-sub app-muted">成功率 {{ emailSuccessRate }}%</div>
</div>
</div>
</div>
<div class="tile-grid tile-grid--3">
<div class="tile">
<div class="tile-v">{{ normalizeCount(emailStats?.total_sent) }}</div>
<div class="tile-k app-muted">总发送</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ normalizeCount(emailStats?.total_success) }}</div>
<div class="tile-k app-muted">成功</div>
</div>
<div class="tile">
<div class="tile-v err">{{ normalizeCount(emailStats?.total_failed) }}</div>
<div class="tile-k app-muted">失败</div>
</div>
</div>
<div class="divider"></div>
<div class="sub-title">类型统计</div>
<div class="type-grid">
<div class="type-item">
<div class="type-v">{{ normalizeCount(emailStats?.register_sent) }}</div>
<div class="type-k app-muted">注册验证</div>
</div>
<div class="type-item">
<div class="type-v">{{ normalizeCount(emailStats?.reset_sent) }}</div>
<div class="type-k app-muted">密码重置</div>
</div>
<div class="type-item">
<div class="type-v">{{ normalizeCount(emailStats?.bind_sent) }}</div>
<div class="type-k app-muted">邮箱绑定</div>
</div>
<div class="type-item">
<div class="type-v">{{ normalizeCount(emailStats?.task_complete_sent) }}</div>
<div class="type-k app-muted">任务完成</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-orange">
<el-icon><ChatLineSquare /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">反馈概览</div>
<div class="panel-sub app-muted">待处理 {{ normalizeCount(feedbackStats?.pending) }} </div>
</div>
</div>
</div>
<div class="tile-grid tile-grid--3">
<div class="tile">
<div class="tile-v">{{ normalizeCount(feedbackStats?.total) }}</div>
<div class="tile-k app-muted">总反馈</div>
</div>
<div class="tile">
<div class="tile-v warn">{{ normalizeCount(feedbackStats?.pending) }}</div>
<div class="tile-k app-muted">待处理</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ normalizeCount(feedbackStats?.replied) }}</div>
<div class="tile-k app-muted">已回复</div>
</div>
</div>
<div class="divider"></div>
<div class="help app-muted">提示用户的反馈需要及时处理避免影响活跃度与留存</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-green">
<el-icon><Cpu /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">系统资源</div>
<div class="panel-sub app-muted">服务器与容器运行状态</div>
</div>
</div>
<el-tag v-if="serverInfo?.uptime" effect="light" type="info">运行 {{ serverInfo.uptime }}</el-tag>
</div>
<div class="resource-grid">
<div class="resource-item">
<div class="resource-k app-muted">CPU</div>
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.cpu_percent))"
:status="parsePercent(serverInfo?.cpu_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.cpu_percent) >= 75 ? 'warning' : 'success'"
/>
<div class="resource-sub app-muted">{{ Math.round(parsePercent(serverInfo?.cpu_percent)) }}%</div>
</div>
<div class="resource-item">
<div class="resource-k app-muted">内存</div>
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.memory_percent))"
:status="parsePercent(serverInfo?.memory_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.memory_percent) >= 75 ? 'warning' : 'success'"
/>
<div class="resource-sub app-muted">
{{ serverInfo?.memory_used || '-' }} / {{ serverInfo?.memory_total || '-' }}{{ Math.round(parsePercent(serverInfo?.memory_percent)) }}%
</div>
</div>
<div class="resource-item">
<div class="resource-k app-muted">磁盘</div>
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.disk_percent))"
:status="parsePercent(serverInfo?.disk_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.disk_percent) >= 75 ? 'warning' : 'success'"
/>
<div class="resource-sub app-muted">
{{ serverInfo?.disk_used || '-' }} / {{ serverInfo?.disk_total || '-' }}{{ Math.round(parsePercent(serverInfo?.disk_percent)) }}%
</div>
</div>
</div>
<div class="divider"></div>
<div class="sub-title">容器</div>
<el-descriptions border :column="2" size="small">
<el-descriptions-item label="状态">{{ dockerStats?.status || '-' }}</el-descriptions-item>
<el-descriptions-item label="容器名">{{ dockerStats?.container_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="运行时长">{{ dockerStats?.uptime || '-' }}</el-descriptions-item>
<el-descriptions-item label="CPU">{{ dockerStats?.cpu_percent || '-' }}</el-descriptions-item>
<el-descriptions-item label="内存">{{ dockerStats?.memory_usage || '-' }}</el-descriptions-item>
<el-descriptions-item label="内存占比">{{ dockerStats?.memory_percent || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="divider"></div>
<div class="panel-head">
<div class="head-left">
<div class="head-text">
<div class="panel-title">截图线程池</div>
<div class="panel-sub app-muted">
活跃有执行环境{{ browserPoolActiveWorkers }} · 忙碌 {{ browserPoolBusyWorkers }} · 队列 {{ browserPoolQueueSize }}
</div>
</div>
</div>
<el-tag v-if="browserPoolStats?.server_time_cst" effect="light" type="info">{{ browserPoolStats.server_time_cst }}</el-tag>
</div>
<div class="tile-grid tile-grid--4">
<div class="tile">
<div class="tile-v">{{ browserPoolTotalWorkers }}</div>
<div class="tile-k app-muted"> Worker</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ browserPoolActiveWorkers }}</div>
<div class="tile-k app-muted">活跃有执行环境</div>
</div>
<div class="tile">
<div class="tile-v">{{ browserPoolIdleWorkers }}</div>
<div class="tile-k app-muted">空闲无任务</div>
</div>
<div class="tile">
<div class="tile-v warn">{{ browserPoolQueueSize }}</div>
<div class="tile-k app-muted">队列等待</div>
</div>
</div>
<div class="divider"></div>
<div class="table-wrap">
<el-table :data="browserPoolWorkers" size="small" border>
<el-table-column prop="worker_id" label="Worker" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="workerPoolStatusType(row)" effect="light">{{ workerPoolStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行" width="90">
<template #default="{ row }">
<el-tag :type="workerRunTagType(row)" effect="light">{{ workerRunLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务" width="120">
<template #default="{ row }">
<span>{{ normalizeCount(row?.total_tasks) }}</span>
<span class="app-muted"> / </span>
<span :class="normalizeCount(row?.failed_tasks) ? 'err' : 'app-muted'">{{ normalizeCount(row?.failed_tasks) }}</span>
</template>
</el-table-column>
<el-table-column prop="browser_use_count" label="复用" width="90" />
<el-table-column prop="last_active_at" label="最近活跃" min-width="160" />
<el-table-column prop="browser_created_at" label="环境创建" min-width="160" />
</el-table>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="never" class="panel" :body-style="{ padding: '16px' }">
<div class="panel-head">
<div class="head-left">
<div class="head-icon tone-red">
<el-icon><Tools /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">配置概览</div>
<div class="panel-sub app-muted">定时 / 代理 / 并发</div>
</div>
</div>
</div>
<div class="config-grid">
<div class="config-item">
<div class="config-k app-muted">定时任务</div>
<div class="config-v">
<el-tag v-if="scheduleEnabled" type="success" effect="light">启用</el-tag>
<el-tag v-else type="info" effect="light">关闭</el-tag>
<span class="config-inline app-muted"> {{ scheduleTime }} / {{ scheduleBrowseType }}</span>
</div>
<div class="config-sub app-muted">日期{{ scheduleWeekdaysDisplay || scheduleWeekdays || '-' }}</div>
</div>
<div class="config-item">
<div class="config-k app-muted">代理</div>
<div class="config-v">
<el-tag v-if="proxyEnabled" type="success" effect="light">启用</el-tag>
<el-tag v-else type="info" effect="light">关闭</el-tag>
<span v-if="proxyEnabled && proxyApiUrl" class="config-inline app-muted">{{ proxyApiUrl }}</span>
</div>
<div class="config-sub app-muted">有效期{{ proxyExpireMinutes || '-' }} 分钟</div>
</div>
<div class="config-item">
<div class="config-k app-muted">并发</div>
<div class="config-v">
<span>全局 {{ maxConcurrentGlobal || '-' }}</span>
<span class="config-split app-muted">/</span>
<span>单账号 {{ maxConcurrentPerAccount || '-' }}</span>
<span class="config-split app-muted">/</span>
<span>截图 {{ maxScreenshotConcurrent || '-' }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.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.65);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.08);
backdrop-filter: saturate(180%) blur(12px);
padding: 16px;
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.hero-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hero-title h2 {
margin: 0;
font-size: 18px;
font-weight: 900;
letter-spacing: 0.2px;
}
.hero-meta {
margin-top: 6px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.hero-dot {
opacity: 0.65;
}
.hero-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.kpi-card {
position: relative;
overflow: hidden;
border-radius: 16px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
padding: 14px;
display: flex;
gap: 12px;
align-items: flex-start;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
}
.kpi-card::before {
content: '';
position: absolute;
inset: 0;
background: var(--kpi-grad, transparent);
opacity: 0.9;
pointer-events: none;
}
.kpi-card::after {
content: '';
position: absolute;
left: 0;
top: 0;
right: 0;
height: 3px;
background: var(--kpi-top, transparent);
opacity: 0.9;
pointer-events: none;
}
.kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
border-color: rgba(17, 24, 39, 0.14);
}
.kpi-icon {
position: relative;
z-index: 1;
width: 38px;
height: 38px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
background: var(--kpi-icon-bg, rgba(59, 130, 246, 0.14));
color: var(--kpi-icon-color, #1d4ed8);
border: 1px solid rgba(17, 24, 39, 0.08);
flex: 0 0 auto;
}
.kpi-body {
position: relative;
z-index: 1;
min-width: 0;
}
.kpi-value {
font-size: 22px;
font-weight: 900;
line-height: 1.1;
}
.kpi-label {
margin-top: 6px;
font-size: 12px;
color: rgba(17, 24, 39, 0.7);
}
.kpi-sub {
margin-top: 6px;
font-size: 12px;
}
.kpi-tone--blue {
--kpi-grad: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(6, 182, 212, 0.1));
--kpi-top: linear-gradient(90deg, #3b82f6, #06b6d4);
--kpi-icon-bg: rgba(59, 130, 246, 0.14);
--kpi-icon-color: #1d4ed8;
}
.kpi-tone--green {
--kpi-grad: linear-gradient(135deg, rgba(16, 185, 129, 0.14), rgba(34, 197, 94, 0.1));
--kpi-top: linear-gradient(90deg, #10b981, #22c55e);
--kpi-icon-bg: rgba(16, 185, 129, 0.14);
--kpi-icon-color: #047857;
}
.kpi-tone--purple {
--kpi-grad: linear-gradient(135deg, rgba(139, 92, 246, 0.14), rgba(236, 72, 153, 0.1));
--kpi-top: linear-gradient(90deg, #8b5cf6, #ec4899);
--kpi-icon-bg: rgba(139, 92, 246, 0.14);
--kpi-icon-color: #6d28d9;
}
.kpi-tone--orange {
--kpi-grad: linear-gradient(135deg, rgba(245, 158, 11, 0.14), rgba(249, 115, 22, 0.1));
--kpi-top: linear-gradient(90deg, #f59e0b, #f97316);
--kpi-icon-bg: rgba(245, 158, 11, 0.14);
--kpi-icon-color: #b45309;
}
.kpi-tone--red {
--kpi-grad: linear-gradient(135deg, rgba(239, 68, 68, 0.14), rgba(244, 63, 94, 0.1));
--kpi-top: linear-gradient(90deg, #ef4444, #f43f5e);
--kpi-icon-bg: rgba(239, 68, 68, 0.14);
--kpi-icon-color: #b91c1c;
}
.kpi-tone--cyan {
--kpi-grad: linear-gradient(135deg, rgba(34, 211, 238, 0.14), rgba(59, 130, 246, 0.1));
--kpi-top: linear-gradient(90deg, #22d3ee, #3b82f6);
--kpi-icon-bg: rgba(34, 211, 238, 0.14);
--kpi-icon-color: #0369a1;
}
.panel {
border-radius: 18px;
border: 1px solid rgba(17, 24, 39, 0.1);
background: rgba(255, 255, 255, 0.7);
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;
}
.tile-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.tile-title {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.2px;
}
.tile-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.tile-grid--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tile-grid--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.tile {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.65);
}
.tile-v {
font-size: 18px;
font-weight: 900;
line-height: 1.1;
}
.tile-k {
margin-top: 6px;
font-size: 12px;
}
.ok {
color: #047857;
}
.warn {
color: #b45309;
}
.err {
color: #b91c1c;
}
.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;
}
.sub-title {
font-size: 13px;
font-weight: 900;
margin-bottom: 10px;
letter-spacing: 0.2px;
}
.type-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.type-item {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.65);
}
.type-v {
font-size: 16px;
font-weight: 900;
}
.type-k {
margin-top: 6px;
font-size: 12px;
}
.table-wrap {
overflow-x: auto;
}
.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.65);
}
.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.65);
}
.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;
}
: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: 768px) {
.kpi-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tile-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tile-grid--3 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tile-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resource-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.kpi-grid {
grid-template-columns: 1fr;
}
}
</style>