添加报表页面,更新用户管理和注册功能
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
687
admin-frontend/src/pages/ReportPage.vue
Normal file
687
admin-frontend/src/pages/ReportPage.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user