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

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

@@ -8,8 +8,8 @@ const props = defineProps({
const items = computed(() => [
{ key: 'total_users', label: '总用户数' },
{ key: 'approved_users', label: '已审核' },
{ key: 'pending_users', label: '待审核' },
{ key: 'new_users_today', label: '今日注册' },
{ key: 'new_users_7d', label: '近7天注册' },
{ key: 'total_accounts', label: '总账号数' },
{ key: 'vip_users', label: 'VIP用户' },
])
@@ -49,4 +49,3 @@ const items = computed(() => [
color: var(--app-muted);
}
</style>

View File

@@ -103,8 +103,8 @@ onBeforeUnmount(() => {
})
const menuItems = [
{ path: '/pending', label: '待审核', icon: Document, badgeKey: 'pending' },
{ path: '/users', label: '用户', icon: User },
{ path: '/reports', label: '报表', icon: Document },
{ path: '/users', label: '用户', icon: User, badgeKey: 'resets' },
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
{ path: '/stats', label: '统计', icon: DataAnalysis },
{ path: '/logs', label: '任务日志', icon: List },
@@ -118,9 +118,7 @@ const activeMenu = computed(() => route.path)
function badgeFor(item) {
if (!item?.badgeKey) return 0
if (item.badgeKey === 'pending') {
return Number(stats.value?.pending_users || 0) + Number(pendingResetsCount.value || 0)
}
if (item.badgeKey === 'resets') return Number(pendingResetsCount.value || 0)
if (item.badgeKey === 'feedbacks') {
return Number(pendingFeedbackCount.value || 0)
}

View File

@@ -1,228 +0,0 @@
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchPendingUsers, approveUser, rejectUser } from '../api/users'
import { approvePasswordReset, fetchPasswordResets, rejectPasswordReset } from '../api/passwordResets'
import { parseSqliteDateTime } from '../utils/datetime'
const refreshStats = inject('refreshStats', null)
const refreshNavBadges = inject('refreshNavBadges', null)
const pendingUsers = ref([])
const passwordResets = ref([])
const loadingPending = ref(false)
const loadingResets = ref(false)
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
if (String(expire).startsWith('2099-12-31')) return true
const dt = parseSqliteDateTime(expire)
return dt ? dt.getTime() > Date.now() : false
}
async function loadPending() {
loadingPending.value = true
try {
pendingUsers.value = await fetchPendingUsers()
} catch {
pendingUsers.value = []
} finally {
loadingPending.value = false
}
}
async function loadResets() {
loadingResets.value = true
try {
passwordResets.value = await fetchPasswordResets()
} catch {
passwordResets.value = []
} finally {
loadingResets.value = false
}
}
async function refreshAll() {
await Promise.all([loadPending(), loadResets()])
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
}
async function onApproveUser(row) {
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}」的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onRejectUser(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}」的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
await refreshAll()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onApproveReset(row) {
try {
await ElMessageBox.confirm(`确定批准「${row.username}」的密码重置申请吗?`, '批准重置', {
confirmButtonText: '批准',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
const res = await approvePasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已批准')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
async function onRejectReset(row) {
try {
await ElMessageBox.confirm(`确定拒绝「${row.username}」的密码重置申请吗?`, '拒绝重置', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await rejectPasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已拒绝')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>待审核</h2>
<div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">用户注册审核</h3>
<div class="table-wrap">
<el-table :data="pendingUsers" v-loading="loadingPending" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户名" min-width="200">
<template #default="{ row }">
<div class="user-cell">
<strong>{{ row.username }}</strong>
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveUser(row)">通过</el-button>
<el-button type="danger" size="small" @click="onRejectUser(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">密码重置审核</h3>
<div class="table-wrap">
<el-table :data="passwordResets" v-loading="loadingResets" style="width: 100%">
<el-table-column prop="id" label="申请ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="200" />
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveReset(row)">批准</el-button>
<el-button type="danger" size="small" @click="onRejectReset(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.table-wrap {
overflow-x: auto;
}
.user-cell {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
</style>

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>

View File

@@ -295,7 +295,7 @@ async function saveAutoApprove() {
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '自动审核配置已保存')
ElMessage.success(res?.message || '注册设置已保存')
} catch {
// handled by interceptor
}
@@ -438,12 +438,12 @@ onBeforeUnmount(stopUpdatePolling)
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">注册自动审核</h3>
<h3 class="section-title">注册设置</h3>
<el-form label-width="130px">
<el-form-item label="启用自动审核">
<el-form-item label="注册赠送VIP">
<el-switch v-model="autoApproveEnabled" />
<div class="help">开启后新用户注册将自动通过审核无需管理员手动审批</div>
<div class="help">开启后新用户注册成功后将赠送下方设置的VIP天数注册已默认无需审核</div>
</el-form-item>
<el-form-item label="每小时注册限制">
@@ -455,7 +455,7 @@ onBeforeUnmount(stopUpdatePolling)
</el-form-item>
</el-form>
<el-button type="primary" @click="saveAutoApprove">保存自动审核配</el-button>
<el-button type="primary" @click="saveAutoApprove">保存注册设</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="updateLoading">

View File

@@ -4,21 +4,26 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminResetUserPassword,
approveUser,
deleteUser,
fetchAllUsers,
approveUser,
rejectUser,
removeUserVip,
setUserVip,
} from '../api/users'
import { approvePasswordReset, fetchPasswordResets, rejectPasswordReset } from '../api/passwordResets'
import { parseSqliteDateTime } from '../utils/datetime'
import { validatePasswordStrength } from '../utils/password'
const refreshStats = inject('refreshStats', null)
const refreshNavBadges = inject('refreshNavBadges', null)
const loading = ref(false)
const users = ref([])
const resetLoading = ref(false)
const passwordResets = ref([])
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
@@ -38,9 +43,8 @@ function vipLabel(user) {
}
function statusMeta(status) {
if (status === 'approved') return { label: '已通过', type: 'success' }
if (status === 'rejected') return { label: '已拒绝', type: 'danger' }
return { label: '待审核', type: 'warning' }
if (status === 'rejected') return { label: '禁用', type: 'danger' }
return { label: '正常', type: 'success' }
}
async function loadUsers() {
@@ -54,10 +58,27 @@ async function loadUsers() {
}
}
async function onApprove(row) {
async function loadResets() {
resetLoading.value = true
try {
await ElMessageBox.confirm(`确定通过用户「${row.username}」的注册申请吗?`, '审核通过', {
confirmButtonText: '通过',
const list = await fetchPasswordResets()
passwordResets.value = Array.isArray(list) ? list : []
} catch {
passwordResets.value = []
} finally {
resetLoading.value = false
}
}
async function refreshAll() {
await Promise.all([loadUsers(), loadResets()])
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
}
async function onEnableUser(row) {
try {
await ElMessageBox.confirm(`确定启用用户「${row.username}」吗?启用后用户可正常登录。`, '启用用户', {
confirmButtonText: '启用',
cancelButtonText: '取消',
type: 'success',
})
@@ -67,7 +88,7 @@ async function onApprove(row) {
try {
await approveUser(row.id)
ElMessage.success('用户审核通过')
ElMessage.success('用户已启用')
await loadUsers()
await refreshStats?.()
} catch {
@@ -75,10 +96,10 @@ async function onApprove(row) {
}
}
async function onReject(row) {
async function onDisableUser(row) {
try {
await ElMessageBox.confirm(`确定拒绝用户「${row.username}的注册申请吗?`, '拒绝申请', {
confirmButtonText: '拒绝',
await ElMessageBox.confirm(`确定禁用用户「${row.username}」吗?禁用后用户将无法登录。`, '禁用用户', {
confirmButtonText: '禁用',
cancelButtonText: '取消',
type: 'warning',
})
@@ -88,7 +109,7 @@ async function onReject(row) {
try {
await rejectUser(row.id)
ElMessage.success('已拒绝用户')
ElMessage.success('用户已禁用')
await loadUsers()
await refreshStats?.()
} catch {
@@ -96,6 +117,48 @@ async function onReject(row) {
}
}
async function onApproveReset(row) {
try {
await ElMessageBox.confirm(`确定批准「${row.username}」的密码重置申请吗?`, '批准重置', {
confirmButtonText: '批准',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
const res = await approvePasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已批准')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
async function onRejectReset(row) {
try {
await ElMessageBox.confirm(`确定拒绝「${row.username}」的密码重置申请吗?`, '拒绝重置', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await rejectPasswordReset(row.id)
ElMessage.success(res?.message || '密码重置申请已拒绝')
await loadResets()
await refreshNavBadges?.({ pendingResets: passwordResets.value.length })
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm(
@@ -200,7 +263,7 @@ async function onResetPassword(row) {
}
}
onMounted(loadUsers)
onMounted(refreshAll)
</script>
<template>
@@ -208,7 +271,7 @@ onMounted(loadUsers)
<div class="app-page-title">
<h2>用户</h2>
<div>
<el-button @click="loadUsers">刷新</el-button>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
@@ -239,17 +302,20 @@ onMounted(loadUsers)
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
<div>{{ row.created_at }}</div>
<div v-if="row.approved_at" class="app-muted">审核: {{ row.approved_at }}</div>
<div v-if="row.vip_expire_time" class="app-muted">VIP到期: {{ row.vip_expire_time }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div class="actions">
<template v-if="row.status === 'pending'">
<el-button type="success" size="small" @click="onApprove(row)">通过</el-button>
<el-button type="warning" size="small" @click="onReject(row)">拒绝</el-button>
</template>
<el-button
v-if="row.status === 'rejected'"
type="success"
size="small"
@click="onEnableUser(row)"
>启用</el-button>
<el-button v-else type="warning" size="small" @click="onDisableUser(row)">禁用</el-button>
<el-dropdown trigger="click">
<el-button size="small">VIP</el-button>
@@ -272,6 +338,27 @@ onMounted(loadUsers)
</el-table>
</div>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">密码重置申请</h3>
<div class="table-wrap">
<el-table :data="passwordResets" v-loading="resetLoading" style="width: 100%">
<el-table-column prop="id" label="申请ID" width="90" />
<el-table-column prop="username" label="用户名" min-width="200" />
<el-table-column prop="email" label="邮箱" min-width="220">
<template #default="{ row }">{{ row.email || '-' }}</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="success" size="small" @click="onApproveReset(row)">批准</el-button>
<el-button type="danger" size="small" @click="onRejectReset(row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="help app-muted">当未启用邮件找回密码时用户会提交申请由管理员在此处处理</div>
</el-card>
</div>
</template>
@@ -287,6 +374,17 @@ onMounted(loadUsers)
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 10px;
font-size: 12px;
}
.table-wrap {
overflow-x: auto;
}

View File

@@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import AdminLayout from '../layouts/AdminLayout.vue'
const PendingPage = () => import('../pages/PendingPage.vue')
const ReportPage = () => import('../pages/ReportPage.vue')
const UsersPage = () => import('../pages/UsersPage.vue')
const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
const StatsPage = () => import('../pages/StatsPage.vue')
@@ -17,8 +17,9 @@ const routes = [
path: '/',
component: AdminLayout,
children: [
{ path: '', redirect: '/pending' },
{ path: '/pending', name: 'pending', component: PendingPage },
{ path: '', redirect: '/reports' },
{ path: '/pending', redirect: '/reports' },
{ path: '/reports', name: 'reports', component: ReportPage },
{ path: '/users', name: 'users', component: UsersPage },
{ path: '/feedbacks', name: 'feedbacks', component: FeedbacksPage },
{ path: '/stats', name: 'stats', component: StatsPage },