添加报表页面,更新用户管理和注册功能

This commit is contained in:
2025-12-15 21:39:32 +08:00
parent 738eaa5211
commit 9aa28f5b9e
63 changed files with 1018 additions and 403 deletions

View File

@@ -0,0 +1,687 @@
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchEmailStats } from '../api/email'
import { fetchPasswordResets } from '../api/passwordResets'
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
import { fetchSystemConfig } from '../api/system'
import { fetchUpdateResult, fetchUpdateStatus } from '../api/update'
const refreshStats = inject('refreshStats', null)
const adminStats = inject('adminStats', null)
const refreshNavBadges = inject('refreshNavBadges', null)
const loading = 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 systemConfig = ref(null)
const updateStatus = ref(null)
const updateStatusError = ref('')
const updateResult = ref(null)
const passwordResetsCount = ref(0)
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 shortCommit(value) {
const text = String(value ?? '').trim()
if (!text) return '-'
return text.length > 12 ? `${text.slice(0, 12)}` : text
}
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 || {}
return [
{ label: '总用户数', value: normalizeCount(s.total_users) },
{ label: '今日注册', value: normalizeCount(s.new_users_today) },
{ label: '近7天注册', value: normalizeCount(s.new_users_7d) },
{ label: '总账号数', value: normalizeCount(s.total_accounts) },
{ label: 'VIP用户', value: normalizeCount(s.vip_users) },
{ label: '运行中任务', value: normalizeCount(runningTasks.value?.running_count) },
{ label: '排队任务', value: normalizeCount(runningTasks.value?.queuing_count) },
{ label: '密码重置待处理', value: normalizeCount(passwordResetsCount.value) },
]
})
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 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() {
if (loading.value) return
loading.value = true
try {
const [
taskResult,
runningResult,
emailResult,
feedbackResult,
resetsResult,
serverResult,
dockerResult,
configResult,
updateStatusResult,
updateResultResult,
] = await Promise.allSettled([
fetchTaskStats(),
fetchRunningTasks(),
fetchEmailStats(),
fetchFeedbackStats(),
fetchPasswordResets(),
fetchServerInfo(),
fetchDockerStats(),
fetchSystemConfig(),
fetchUpdateStatus(),
fetchUpdateResult(),
])
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
passwordResetsCount.value = resetsResult.status === 'fulfilled' ? (Array.isArray(resetsResult.value) ? resetsResult.value.length : 0) : 0
serverInfo.value = serverResult.status === 'fulfilled' ? serverResult.value : null
dockerStats.value = dockerResult.status === 'fulfilled' ? dockerResult.value : null
systemConfig.value = configResult.status === 'fulfilled' ? configResult.value : null
if (updateStatusResult.status === 'fulfilled') {
const res = updateStatusResult.value
if (res?.ok) {
updateStatus.value = res.data || null
updateStatusError.value = ''
} else {
updateStatus.value = null
updateStatusError.value = res?.error || '未发现更新状态Update-Agent 可能未运行)'
}
} else {
updateStatus.value = null
updateStatusError.value = ''
}
updateResult.value = updateResultResult.status === 'fulfilled' && updateResultResult.value?.ok ? updateResultResult.value.data : null
await refreshNavBadges?.({ pendingResets: passwordResetsCount.value })
await refreshStats?.()
recordUpdatedAt()
} finally {
loading.value = false
}
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<div class="title-group">
<h2>报表</h2>
<span class="app-muted">{{ lastUpdatedAt ? `更新时间:${lastUpdatedAt}` : '' }}</span>
</div>
<div class="toolbar">
<el-button :loading="loading" @click="refreshAll">刷新</el-button>
</div>
</div>
<el-row :gutter="12">
<el-col v-for="c in overviewCards" :key="c.label" :xs="12" :sm="12" :md="6">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="metric-label">{{ c.label }}</div>
<div class="metric-value">{{ c.value }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :md="14">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">系统状态</h3>
<el-tag v-if="serverInfo?.uptime" effect="light" type="info">运行 {{ serverInfo.uptime }}</el-tag>
</div>
<div class="sys-grid">
<div class="sys-item">
<div class="sys-k app-muted">CPU</div>
<div class="sys-v">
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.cpu_percent))"
:status="parsePercent(serverInfo?.cpu_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.cpu_percent) >= 75 ? 'warning' : 'success'"
/>
</div>
<div class="sys-sub app-muted">{{ Math.round(parsePercent(serverInfo?.cpu_percent)) }}%</div>
</div>
<div class="sys-item">
<div class="sys-k app-muted">内存</div>
<div class="sys-v">
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.memory_percent))"
:status="parsePercent(serverInfo?.memory_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.memory_percent) >= 75 ? 'warning' : 'success'"
/>
</div>
<div class="sys-sub app-muted">
{{ serverInfo?.memory_used || '-' }} / {{ serverInfo?.memory_total || '-' }}{{ Math.round(parsePercent(serverInfo?.memory_percent)) }}%
</div>
</div>
<div class="sys-item">
<div class="sys-k app-muted">磁盘</div>
<div class="sys-v">
<el-progress
:percentage="Math.round(parsePercent(serverInfo?.disk_percent))"
:status="parsePercent(serverInfo?.disk_percent) >= 90 ? 'exception' : parsePercent(serverInfo?.disk_percent) >= 75 ? 'warning' : 'success'"
/>
</div>
<div class="sys-sub app-muted">
{{ serverInfo?.disk_used || '-' }} / {{ serverInfo?.disk_total || '-' }}{{ Math.round(parsePercent(serverInfo?.disk_percent)) }}%
</div>
</div>
</div>
<div class="divider"></div>
<div class="section-head">
<h3 class="section-title">容器状态</h3>
<el-tag v-if="dockerStats?.status" effect="light" :type="dockerStats?.running ? 'success' : 'info'">
{{ dockerStats?.status }}
</el-tag>
</div>
<el-descriptions border :column="2" size="small">
<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_limit || '-' }}</el-descriptions-item>
<el-descriptions-item label="内存占比">{{ dockerStats?.memory_percent || '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">配置与更新</h3>
<el-tag v-if="updateStatus?.update_available" effect="light" type="warning">发现新版本</el-tag>
</div>
<el-descriptions border :column="1" size="small">
<el-descriptions-item label="定时任务">
<el-tag v-if="scheduleEnabled" type="success" effect="light">启用</el-tag>
<el-tag v-else type="info" effect="light">关闭</el-tag>
<span class="desc-inline app-muted"> {{ scheduleTime }} / {{ scheduleBrowseType }}</span>
</el-descriptions-item>
<el-descriptions-item label="执行日期">
<span>{{ scheduleWeekdaysDisplay || scheduleWeekdays || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="并发配置">
<span>全局 {{ maxConcurrentGlobal || '-' }} / 单账号 {{ maxConcurrentPerAccount || '-' }} / 截图 {{ maxScreenshotConcurrent || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="代理">
<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="desc-inline app-muted"> {{ proxyApiUrl }}</span>
</el-descriptions-item>
<el-descriptions-item label="代理有效期">{{ proxyExpireMinutes || '-' }} 分钟</el-descriptions-item>
</el-descriptions>
<div class="divider"></div>
<div class="sub-title">版本信息</div>
<el-descriptions border :column="1" size="small">
<el-descriptions-item label="本地版本(commit)">{{ shortCommit(updateStatus?.local_commit) }}</el-descriptions-item>
<el-descriptions-item label="远端版本(commit)">{{ shortCommit(updateStatus?.remote_commit) }}</el-descriptions-item>
<el-descriptions-item label="最近检查时间">{{ updateStatus?.checked_at || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="updateStatusError" label="更新状态">{{ updateStatusError }}</el-descriptions-item>
<el-descriptions-item v-if="updateResult?.job_id" label="最近更新">
<span>job {{ updateResult.job_id }} / {{ updateResult?.status || '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :xs="24" :md="14">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">任务报表今日</h3>
<el-tag v-if="taskTodaySuccessRate > 0" effect="light" type="success">成功率 {{ taskTodaySuccessRate }}%</el-tag>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v ok">{{ normalizeCount(taskToday.success_tasks) }}</div>
<div class="kv-k app-muted">成功任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v err">{{ normalizeCount(taskToday.failed_tasks) }}</div>
<div class="kv-k app-muted">失败任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskToday.total_tasks) }}</div>
<div class="kv-k app-muted">总任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskToday.total_items) }}</div>
<div class="kv-k app-muted">浏览内容</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskToday.total_attachments) }}</div>
<div class="kv-k app-muted">查看附件</div>
</div>
</el-col>
</el-row>
<div class="divider"></div>
<div class="section-head">
<h3 class="section-title">任务报表累计</h3>
</div>
<el-row :gutter="12">
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v ok">{{ normalizeCount(taskTotal.success_tasks) }}</div>
<div class="kv-k app-muted">成功任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v err">{{ normalizeCount(taskTotal.failed_tasks) }}</div>
<div class="kv-k app-muted">失败任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskTotal.total_tasks) }}</div>
<div class="kv-k app-muted">总任务</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskTotal.total_items) }}</div>
<div class="kv-k app-muted">浏览内容</div>
</div>
</el-col>
<el-col :xs="12" :sm="6">
<div class="kv">
<div class="kv-v">{{ normalizeCount(taskTotal.total_attachments) }}</div>
<div class="kv-k app-muted">查看附件</div>
</div>
</el-col>
</el-row>
<div class="divider"></div>
<div class="section-head">
<h3 class="section-title">当前队列</h3>
<span class="app-muted">{{ runningCountsLabel }}</span>
</div>
<div v-if="runningTaskList.length || queuingTaskList.length" class="table-wrap">
<el-table :data="runningTaskList.slice(0, 8)" size="small" style="width: 100%">
<el-table-column label="用户" min-width="140">
<template #default="{ row }">{{ row.user_username || '-' }}</template>
</el-table-column>
<el-table-column label="账号" min-width="160">
<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="100">
<template #default="{ row }">{{ row.browse_type || '-' }}</template>
</el-table-column>
<el-table-column label="进度" width="110">
<template #default="{ row }">{{ row.progress_items }}/{{ row.progress_attachments }}</template>
</el-table-column>
<el-table-column label="耗时" width="110">
<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 v-if="queuingTaskList.length" class="help app-muted">排队中{{ queuingTaskList.length }} 个任务</div>
</div>
<div v-else class="help app-muted">当前无运行/排队任务</div>
</el-card>
</el-col>
<el-col :xs="24" :md="10">
<el-card shadow="never" class="card" :body-style="{ padding: '16px' }">
<div class="section-head">
<h3 class="section-title">邮件报表</h3>
<el-tag v-if="emailSuccessRate > 0" effect="light" type="success">成功率 {{ emailSuccessRate }}%</el-tag>
</div>
<el-row :gutter="12">
<el-col :span="8">
<div class="kv">
<div class="kv-v">{{ normalizeCount(emailStats?.total_sent) }}</div>
<div class="kv-k app-muted">总发送</div>
</div>
</el-col>
<el-col :span="8">
<div class="kv">
<div class="kv-v ok">{{ normalizeCount(emailStats?.total_success) }}</div>
<div class="kv-k app-muted">成功</div>
</div>
</el-col>
<el-col :span="8">
<div class="kv">
<div class="kv-v err">{{ normalizeCount(emailStats?.total_failed) }}</div>
<div class="kv-k app-muted">失败</div>
</div>
</el-col>
</el-row>
<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>
<div class="divider"></div>
<div class="section-head">
<h3 class="section-title">反馈概览</h3>
</div>
<el-row :gutter="12">
<el-col :span="8">
<div class="kv">
<div class="kv-v">{{ normalizeCount(feedbackStats?.total) }}</div>
<div class="kv-k app-muted">总反馈</div>
</div>
</el-col>
<el-col :span="8">
<div class="kv">
<div class="kv-v warn">{{ normalizeCount(feedbackStats?.pending) }}</div>
<div class="kv-k app-muted">待处理</div>
</div>
</el-col>
<el-col :span="8">
<div class="kv">
<div class="kv-v ok">{{ normalizeCount(feedbackStats?.replied) }}</div>
<div class="kv-k app-muted">已回复</div>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.title-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.metric-label {
font-size: 12px;
color: var(--app-muted);
}
.metric-value {
margin-top: 6px;
font-size: 22px;
font-weight: 900;
line-height: 1.1;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 800;
}
.kv {
border: 1px solid var(--app-border);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.kv-v {
font-size: 18px;
font-weight: 900;
line-height: 1.1;
}
.kv-k {
margin-top: 6px;
font-size: 12px;
}
.ok {
color: #047857;
}
.warn {
color: #b45309;
}
.err {
color: #b91c1c;
}
.divider {
height: 1px;
background: var(--app-border);
margin: 14px 0;
}
.sys-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.sys-item {
border: 1px solid var(--app-border);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.sys-k {
font-size: 12px;
}
.sys-sub {
margin-top: 8px;
font-size: 12px;
}
.desc-inline {
margin-left: 8px;
}
.sub-title {
font-size: 13px;
font-weight: 800;
margin-bottom: 10px;
}
.type-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.type-item {
border: 1px solid var(--app-border);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.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;
}
@media (max-width: 768px) {
.sys-grid {
grid-template-columns: 1fr;
}
}
</style>