1206 lines
38 KiB
Vue
1206 lines
38 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, 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>
|