feat: 添加安全模块 + Dockerfile添加curl支持健康检查

主要更新:
- 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等)
- Dockerfile 添加 curl 以支持 Docker 健康检查
- 前端页面更新 (管理后台、用户端)
- 数据库迁移和 schema 更新
- 新增 kdocs 上传服务
- 添加安全相关测试用例

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yu Yon
2026-01-08 17:48:33 +08:00
parent e3b0c35da6
commit 53c78e8e3c
76 changed files with 8563 additions and 4709 deletions

View File

@@ -10,6 +10,13 @@ export async function createAnnouncement(payload) {
return data
}
export async function uploadAnnouncementImage(file) {
const formData = new FormData()
formData.append('file', file)
const { data } = await api.post('/announcements/upload_image', formData)
return data
}
export async function activateAnnouncement(id) {
const { data } = await api.post(`/announcements/${id}/activate`)
return data
@@ -24,4 +31,3 @@ export async function deleteAnnouncement(id) {
const { data } = await api.delete(`/announcements/${id}`)
return data
}

View File

@@ -0,0 +1,7 @@
import { api } from './client'
export async function fetchBrowserPoolStats() {
const { data } = await api.get('/browser_pool/stats')
return data
}

View File

@@ -0,0 +1,17 @@
import { api } from './client'
export async function fetchKdocsStatus(params = {}) {
const { data } = await api.get('/kdocs/status', { params })
return data
}
export async function fetchKdocsQr(payload = {}) {
const body = { force: true, ...payload }
const { data } = await api.post('/kdocs/qr', body)
return data
}
export async function clearKdocsLogin() {
const { data } = await api.post('/kdocs/clear-login', {})
return data
}

View File

@@ -0,0 +1,63 @@
import { api } from './client'
export async function getDashboard() {
const { data } = await api.get('/admin/security/dashboard')
return data
}
export async function getThreats(params) {
const { data } = await api.get('/admin/security/threats', { params })
return data
}
export async function getBannedIps() {
const { data } = await api.get('/admin/security/banned-ips')
return data
}
export async function getBannedUsers() {
const { data } = await api.get('/admin/security/banned-users')
return data
}
export async function banIp(payload) {
const { data } = await api.post('/admin/security/ban-ip', payload)
return data
}
export async function unbanIp(ip) {
const { data } = await api.post('/admin/security/unban-ip', { ip })
return data
}
export async function banUser(payload) {
const { data } = await api.post('/admin/security/ban-user', payload)
return data
}
export async function unbanUser(userId) {
const { data } = await api.post('/admin/security/unban-user', { user_id: userId })
return data
}
export async function getIpRisk(ip) {
const safeIp = encodeURIComponent(String(ip || '').trim())
const { data } = await api.get(`/admin/security/ip-risk/${safeIp}`)
return data
}
export async function clearIpRisk(ip) {
const { data } = await api.post('/admin/security/ip-risk/clear', { ip })
return data
}
export async function getUserRisk(userId) {
const safeUserId = encodeURIComponent(String(userId || '').trim())
const { data } = await api.get(`/admin/security/user-risk/${safeUserId}`)
return data
}
export async function cleanup() {
const { data } = await api.post('/admin/security/cleanup', {})
return data
}

View File

@@ -7,6 +7,7 @@ import {
ChatLineSquare,
Document,
List,
Lock,
Message,
Setting,
Tools,
@@ -15,7 +16,6 @@ import {
import { api } from '../api/client'
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchPasswordResets } from '../api/passwordResets'
import { fetchSystemStats } from '../api/stats'
const route = useRoute()
@@ -33,15 +33,11 @@ async function refreshStats() {
}
const loadingBadges = ref(false)
const pendingResetsCount = ref(0)
const pendingFeedbackCount = ref(0)
let badgeTimer
async function refreshNavBadges(partial = null) {
if (partial && typeof partial === 'object') {
if (Object.prototype.hasOwnProperty.call(partial, 'pendingResets')) {
pendingResetsCount.value = Number(partial.pendingResets || 0)
}
if (Object.prototype.hasOwnProperty.call(partial, 'pendingFeedbacks')) {
pendingFeedbackCount.value = Number(partial.pendingFeedbacks || 0)
}
@@ -52,18 +48,8 @@ async function refreshNavBadges(partial = null) {
loadingBadges.value = true
try {
const [resetsResult, feedbackResult] = await Promise.allSettled([
fetchPasswordResets(),
fetchFeedbackStats(),
])
if (resetsResult.status === 'fulfilled') {
pendingResetsCount.value = Array.isArray(resetsResult.value) ? resetsResult.value.length : 0
}
if (feedbackResult.status === 'fulfilled') {
pendingFeedbackCount.value = Number(feedbackResult.value?.pending || 0)
}
const feedbackResult = await fetchFeedbackStats()
pendingFeedbackCount.value = Number(feedbackResult?.pending || 0)
} finally {
loadingBadges.value = false
}
@@ -99,11 +85,12 @@ onBeforeUnmount(() => {
const menuItems = [
{ path: '/reports', label: '报表', icon: Document },
{ path: '/users', label: '用户', icon: User, badgeKey: 'resets' },
{ path: '/users', label: '用户', icon: User },
{ path: '/feedbacks', label: '反馈', icon: ChatLineSquare, badgeKey: 'feedbacks' },
{ path: '/logs', label: '任务日志', icon: List },
{ path: '/announcements', label: '公告', icon: Bell },
{ path: '/email', label: '邮件', icon: Message },
{ path: '/security', label: '安全防护', icon: Lock },
{ path: '/system', label: '系统配置', icon: Tools },
{ path: '/settings', label: '设置', icon: Setting },
]
@@ -112,7 +99,6 @@ const activeMenu = computed(() => route.path)
function badgeFor(item) {
if (!item?.badgeKey) return 0
if (item.badgeKey === 'resets') return Number(pendingResetsCount.value || 0)
if (item.badgeKey === 'feedbacks') {
return Number(pendingFeedbackCount.value || 0)
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted, ref } from 'vue'
import { h, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import {
activateAnnouncement,
@@ -8,10 +9,14 @@ import {
deactivateAnnouncement,
deleteAnnouncement,
fetchAnnouncements,
uploadAnnouncementImage,
} from '../api/announcements'
const formTitle = ref('')
const formContent = ref('')
const formImageUrl = ref('')
const imageInputRef = ref(null)
const uploading = ref(false)
const loading = ref(false)
const list = ref([])
@@ -30,18 +35,56 @@ async function load() {
function clearForm() {
formTitle.value = ''
formContent.value = ''
formImageUrl.value = ''
if (imageInputRef.value) imageInputRef.value.value = ''
}
function openImagePicker() {
imageInputRef.value?.click()
}
function clearImage() {
formImageUrl.value = ''
if (imageInputRef.value) imageInputRef.value.value = ''
}
async function onImageFileChange(event) {
const file = event.target?.files?.[0]
if (!file) return
if (file.type && !file.type.startsWith('image/')) {
ElMessage.error('请选择图片文件')
event.target.value = ''
return
}
uploading.value = true
try {
const res = await uploadAnnouncementImage(file)
if (!res?.success || !res?.url) {
ElMessage.error(res?.error || '上传失败')
return
}
formImageUrl.value = res.url
ElMessage.success('上传成功')
} catch {
// handled by interceptor
} finally {
uploading.value = false
event.target.value = ''
}
}
async function submit(isActive) {
const title = formTitle.value.trim()
const content = formContent.value.trim()
const image_url = formImageUrl.value.trim()
if (!title || !content) {
ElMessage.error('标题和内容不能为空')
return
}
try {
const res = await createAnnouncement({ title, content, is_active: Boolean(isActive) })
const res = await createAnnouncement({ title, content, image_url, is_active: Boolean(isActive) })
if (!res?.success) {
ElMessage.error(res?.error || '保存失败')
return
@@ -55,7 +98,17 @@ async function submit(isActive) {
}
async function view(row) {
await ElMessageBox.alert(row.content || '', row.title || '公告', {
const body = h('div', { class: 'announcement-view' }, [
row.content ? h('div', { class: 'announcement-view-text' }, row.content) : null,
row.image_url
? h('img', {
class: 'announcement-view-image',
src: row.image_url,
alt: '公告图片',
})
: null,
])
await ElMessageBox.alert(body, row.title || '公告', {
confirmButtonText: '关闭',
dangerouslyUseHTMLString: false,
})
@@ -162,8 +215,26 @@ onMounted(load)
show-word-limit
/>
</el-form-item>
<el-form-item label="公告图片">
<div class="image-upload-row">
<el-button :icon="Plus" :loading="uploading" @click="openImagePicker">上传图片</el-button>
<el-button v-if="formImageUrl" @click="clearImage">移除</el-button>
<span v-if="formImageUrl" class="image-url">{{ formImageUrl }}</span>
<input
ref="imageInputRef"
class="image-input"
type="file"
accept="image/*"
@change="onImageFileChange"
/>
</div>
</el-form-item>
</el-form>
<div v-if="formImageUrl" class="image-preview">
<img :src="formImageUrl" alt="公告图片预览" />
</div>
<div class="actions">
<el-button type="primary" @click="submit(true)">发布并启用</el-button>
<el-button @click="submit(false)">保存但不启用</el-button>
@@ -193,6 +264,12 @@ onMounted(load)
</el-tag>
</template>
</el-table-column>
<el-table-column label="图片" width="100">
<template #default="{ row }">
<el-tag v-if="row.image_url" type="success" effect="light">有图</el-tag>
<span v-else class="app-muted">-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
@@ -234,6 +311,57 @@ onMounted(load)
color: var(--app-muted);
}
.image-preview {
margin: 6px 0 2px;
display: flex;
justify-content: flex-start;
}
.image-preview img {
max-width: 280px;
max-height: 160px;
border-radius: 8px;
border: 1px solid var(--app-border);
object-fit: contain;
}
.image-upload-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.image-input {
display: none;
}
.image-url {
font-size: 12px;
color: var(--app-muted);
word-break: break-all;
}
.announcement-view {
display: flex;
flex-direction: column;
gap: 12px;
}
.announcement-view-text {
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.announcement-view-image {
max-width: 100%;
max-height: 320px;
border-radius: 10px;
border: 1px solid var(--app-border);
object-fit: contain;
}
.table-wrap {
overflow-x: auto;
}
@@ -252,4 +380,3 @@ onMounted(load)
gap: 8px;
}
</style>

View File

@@ -21,6 +21,7 @@ const settings = reactive({
enabled: false,
failover_enabled: true,
register_verify_enabled: false,
login_alert_enabled: true,
task_notify_enabled: false,
base_url: '',
updated_at: null,
@@ -35,6 +36,7 @@ async function loadEmailSettings() {
settings.enabled = Boolean(data.enabled)
settings.failover_enabled = Boolean(data.failover_enabled)
settings.register_verify_enabled = Boolean(data.register_verify_enabled)
settings.login_alert_enabled = data.login_alert_enabled === undefined ? true : Boolean(data.login_alert_enabled)
settings.task_notify_enabled = Boolean(data.task_notify_enabled)
settings.base_url = data.base_url || ''
settings.updated_at = data.updated_at || null
@@ -53,6 +55,7 @@ async function saveEmailSettings() {
enabled: settings.enabled,
failover_enabled: settings.failover_enabled,
register_verify_enabled: settings.register_verify_enabled,
login_alert_enabled: settings.login_alert_enabled,
task_notify_enabled: settings.task_notify_enabled,
base_url: (settings.base_url || '').trim(),
})
@@ -597,6 +600,8 @@ onMounted(refreshAll)
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-divider content-position="left">通知设置</el-divider>
<el-form-item label="启用任务完成通知">
<el-switch
v-model="settings.task_notify_enabled"
@@ -604,6 +609,14 @@ onMounted(refreshAll)
@change="scheduleSaveEmailSettings"
/>
</el-form-item>
<el-form-item label="新设备登录提醒">
<el-switch
v-model="settings.login_alert_enabled"
:disabled="emailSettingsSaving"
@change="scheduleSaveEmailSettings"
/>
<div class="help">当检测到新设备或新IP登录时发送邮件提醒用户</div>
</el-form-item>
<el-form-item label="网站基础URL">
<el-input
v-model="settings.base_url"

View File

@@ -1,12 +1,11 @@
<script setup>
import { computed, inject, onMounted, ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref } from 'vue'
import {
Calendar,
ChatLineSquare,
Clock,
Cpu,
Key,
Lock,
Loading,
Message,
Star,
@@ -18,16 +17,15 @@ import {
import { fetchFeedbackStats } from '../api/feedbacks'
import { fetchEmailStats } from '../api/email'
import { fetchPasswordResets } from '../api/passwordResets'
import { fetchDockerStats, fetchRunningTasks, fetchServerInfo, fetchTaskStats } from '../api/tasks'
import { fetchBrowserPoolStats } from '../api/browser_pool'
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 refreshing = ref(false)
const lastUpdatedAt = ref('')
const taskStats = ref(null)
@@ -36,11 +34,8 @@ const emailStats = ref(null)
const feedbackStats = ref(null)
const serverInfo = ref(null)
const dockerStats = ref(null)
const browserPoolStats = ref(null)
const systemConfig = ref(null)
const updateStatus = ref(null)
const updateStatusError = ref('')
const updateResult = ref(null)
const passwordResetsCount = ref(0)
const queueTab = ref('running')
function recordUpdatedAt() {
@@ -67,12 +62,6 @@ function parsePercent(value) {
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 '手动'
@@ -101,7 +90,6 @@ const overviewCards = computed(() => {
sub: liveMax ? `并发上限 ${liveMax}` : '',
},
{ label: '排队任务', value: normalizeCount(runningTasks.value?.queuing_count), icon: Clock, tone: 'purple' },
{ label: '密码重置待处理', value: normalizeCount(passwordResetsCount.value), icon: Lock, tone: 'red' },
]
})
@@ -112,6 +100,40 @@ const queuingTaskList = computed(() => runningTasks.value?.queuing || [])
const runningCount = computed(() => normalizeCount(runningTasks.value?.running_count))
const queuingCount = computed(() => normalizeCount(runningTasks.value?.queuing_count))
const browserPoolWorkers = computed(() => {
const workers = browserPoolStats.value?.workers
if (!Array.isArray(workers)) return []
return [...workers].sort((a, b) => normalizeCount(a?.worker_id) - normalizeCount(b?.worker_id))
})
const browserPoolTotalWorkers = computed(() => normalizeCount(browserPoolStats.value?.total_workers))
const browserPoolActiveWorkers = computed(() => browserPoolWorkers.value.filter((w) => Boolean(w?.has_browser)).length)
const browserPoolIdleWorkers = computed(() => normalizeCount(browserPoolStats.value?.idle_workers))
const browserPoolQueueSize = computed(() => normalizeCount(browserPoolStats.value?.queue_size))
const browserPoolBusyWorkers = computed(() => normalizeCount(browserPoolStats.value?.active_workers))
function workerPoolStatusType(worker) {
if (!worker?.thread_alive) return 'danger'
if (worker?.has_browser) return 'success'
return 'info'
}
function workerPoolStatusLabel(worker) {
if (!worker?.thread_alive) return '异常'
if (worker?.has_browser) return '活跃'
return '空闲'
}
function workerRunTagType(worker) {
if (!worker?.thread_alive) return 'danger'
return worker?.idle ? 'info' : 'warning'
}
function workerRunLabel(worker) {
if (!worker?.thread_alive) return '停止'
return worker?.idle ? '空闲' : '忙碌'
}
const taskTodaySuccessRate = computed(() => {
const success = normalizeCount(taskToday.value.success_tasks)
const failed = normalizeCount(taskToday.value.failed_tasks)
@@ -160,71 +182,70 @@ const runningCountsLabel = computed(() => {
return `运行中 ${runningCount} / 排队 ${queuingCount} / 并发上限 ${maxGlobal || maxConcurrentGlobal.value || '-'}`
})
const updateAvailable = computed(() => Boolean(updateStatus.value?.update_available))
const updateRunning = computed(() => updateResult.value?.status === 'running')
async function refreshAll() {
if (loading.value) return
loading.value = true
async function refreshAll(options = {}) {
const showLoading = options.showLoading ?? true
if (refreshing.value) return
refreshing.value = true
if (showLoading) {
loading.value = true
}
try {
const [
taskResult,
runningResult,
emailResult,
feedbackResult,
resetsResult,
serverResult,
dockerResult,
browserPoolResult,
configResult,
updateStatusResult,
updateResultResult,
] = await Promise.allSettled([
fetchTaskStats(),
fetchRunningTasks(),
fetchEmailStats(),
fetchFeedbackStats(),
fetchPasswordResets(),
fetchServerInfo(),
fetchDockerStats(),
fetchBrowserPoolStats(),
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
browserPoolStats.value = browserPoolResult.status === 'fulfilled' ? browserPoolResult.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
refreshing.value = false
if (showLoading) {
loading.value = false
}
}
}
onMounted(refreshAll)
let refreshTimer = null
function manualRefresh() {
return refreshAll({ showLoading: true })
}
onMounted(() => {
refreshAll({ showLoading: false })
refreshTimer = setInterval(() => refreshAll({ showLoading: false }), 1000)
})
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
</script>
<template>
@@ -234,10 +255,6 @@ onMounted(refreshAll)
<div class="hero-title">
<div class="hero-title-row">
<h2>报表中心</h2>
<el-tag v-if="updateStatusError" type="info" effect="dark">更新状态未知</el-tag>
<el-tag v-else-if="updateAvailable" type="warning" effect="dark">新版本可更新</el-tag>
<el-tag v-else type="success" effect="dark">已是最新</el-tag>
<el-tag v-if="updateRunning" type="warning" effect="plain">更新中</el-tag>
</div>
<div class="hero-meta app-muted">
<span v-if="lastUpdatedAt">更新时间{{ lastUpdatedAt }}</span>
@@ -247,7 +264,7 @@ onMounted(refreshAll)
</div>
<div class="hero-actions">
<el-button type="primary" plain :loading="loading" @click="refreshAll">刷新</el-button>
<el-button type="primary" plain :loading="loading" @click="manualRefresh">刷新</el-button>
</div>
</div>
@@ -582,6 +599,67 @@ onMounted(refreshAll)
<el-descriptions-item label="内存">{{ dockerStats?.memory_usage || '-' }}</el-descriptions-item>
<el-descriptions-item label="内存占比">{{ dockerStats?.memory_percent || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="divider"></div>
<div class="panel-head">
<div class="head-left">
<div class="head-text">
<div class="panel-title">截图线程池</div>
<div class="panel-sub app-muted">
活跃有执行环境{{ browserPoolActiveWorkers }} · 忙碌 {{ browserPoolBusyWorkers }} · 队列 {{ browserPoolQueueSize }}
</div>
</div>
</div>
<el-tag v-if="browserPoolStats?.server_time_cst" effect="light" type="info">{{ browserPoolStats.server_time_cst }}</el-tag>
</div>
<div class="tile-grid tile-grid--4">
<div class="tile">
<div class="tile-v">{{ browserPoolTotalWorkers }}</div>
<div class="tile-k app-muted"> Worker</div>
</div>
<div class="tile">
<div class="tile-v ok">{{ browserPoolActiveWorkers }}</div>
<div class="tile-k app-muted">活跃有执行环境</div>
</div>
<div class="tile">
<div class="tile-v">{{ browserPoolIdleWorkers }}</div>
<div class="tile-k app-muted">空闲无任务</div>
</div>
<div class="tile">
<div class="tile-v warn">{{ browserPoolQueueSize }}</div>
<div class="tile-k app-muted">队列等待</div>
</div>
</div>
<div class="divider"></div>
<div class="table-wrap">
<el-table :data="browserPoolWorkers" size="small" border>
<el-table-column prop="worker_id" label="Worker" width="90" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="workerPoolStatusType(row)" effect="light">{{ workerPoolStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行" width="90">
<template #default="{ row }">
<el-tag :type="workerRunTagType(row)" effect="light">{{ workerRunLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务" width="120">
<template #default="{ row }">
<span>{{ normalizeCount(row?.total_tasks) }}</span>
<span class="app-muted"> / </span>
<span :class="normalizeCount(row?.failed_tasks) ? 'err' : 'app-muted'">{{ normalizeCount(row?.failed_tasks) }}</span>
</template>
</el-table-column>
<el-table-column prop="browser_use_count" label="复用" width="90" />
<el-table-column prop="last_active_at" label="最近活跃" min-width="160" />
<el-table-column prop="browser_created_at" label="环境创建" min-width="160" />
</el-table>
</div>
</el-card>
</el-col>
@@ -593,21 +671,12 @@ onMounted(refreshAll)
<el-icon><Tools /></el-icon>
</div>
<div class="head-text">
<div class="panel-title">配置与更新</div>
<div class="panel-sub app-muted">定时/代理/并发与版本</div>
<div class="panel-title">配置概览</div>
<div class="panel-sub app-muted">定时 / 代理 / 并发</div>
</div>
</div>
<el-tag v-if="updateAvailable" effect="dark" type="warning">可更新</el-tag>
</div>
<el-alert
v-if="updateStatusError"
type="info"
:closable="false"
:title="updateStatusError"
style="margin-bottom: 12px"
/>
<div class="config-grid">
<div class="config-item">
<div class="config-k app-muted">定时任务</div>
@@ -640,18 +709,6 @@ onMounted(refreshAll)
</div>
</div>
</div>
<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="updateResult?.job_id" label="最近更新">
<span>job {{ updateResult.job_id }} / {{ updateResult?.status || '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
@@ -956,6 +1013,10 @@ onMounted(refreshAll)
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tile-grid--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.tile {
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
@@ -1127,6 +1188,10 @@ onMounted(refreshAll)
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tile-grid--4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resource-grid {
grid-template-columns: 1fr;
}

View File

@@ -0,0 +1,843 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
banIp,
banUser,
cleanup,
clearIpRisk,
getBannedIps,
getBannedUsers,
getDashboard,
getIpRisk,
getThreats,
getUserRisk,
unbanIp,
unbanUser,
} from '../api/security'
const pageSize = 20
const activeTab = ref('threats')
const dashboardLoading = ref(false)
const dashboard = ref(null)
const threatsLoading = ref(false)
const threatItems = ref([])
const threatTotal = ref(0)
const threatPage = ref(1)
const threatTypeFilter = ref('')
const threatSeverityFilter = ref('')
const bansLoading = ref(false)
const bannedIps = ref([])
const bannedUsers = ref([])
const banTab = ref('ips')
const banDialogOpen = ref(false)
const banSubmitting = ref(false)
const banForm = ref({
kind: 'ip',
ip: '',
user_id: '',
reason: '',
duration_hours: 24,
permanent: false,
})
const riskTab = ref('ip')
const riskLoading = ref(false)
const riskIpInput = ref('')
const riskUserIdInput = ref('')
const riskResult = ref(null)
const riskResultKind = ref('')
const commonThreatTypes = [
'sql_injection',
'xss',
'path_traversal',
'command_injection',
'ssrf',
'scanner',
'bruteforce',
'csrf',
'xxe',
'file_upload',
]
function normalizeCount(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
function scoreMeta(score) {
const n = Number(score || 0)
if (n >= 80) return { label: '高', type: 'danger' }
if (n >= 50) return { label: '中', type: 'warning' }
return { label: '低', type: 'success' }
}
function formatExpires(expiresAt) {
const text = String(expiresAt || '').trim()
return text ? text : '永久'
}
function payloadTooltip(row) {
const parts = []
if (row?.field_name) parts.push(`字段: ${row.field_name}`)
if (row?.rule) parts.push(`规则: ${row.rule}`)
if (row?.matched) parts.push(`匹配: ${row.matched}`)
if (row?.value_preview) parts.push(`值: ${row.value_preview}`)
return parts.length ? parts.join(' · ') : '-'
}
function pathText(row) {
const method = String(row?.request_method || '').trim()
const path = String(row?.request_path || '').trim()
const combined = `${method} ${path}`.trim()
return combined || '-'
}
const threatTypeOptions = computed(() => {
const seen = new Set(commonThreatTypes)
const recent = dashboard.value?.recent_threat_events || []
for (const item of recent) {
const t = String(item?.threat_type || '').trim()
if (t) seen.add(t)
}
for (const item of threatItems.value || []) {
const t = String(item?.threat_type || '').trim()
if (t) seen.add(t)
}
return Array.from(seen)
.sort((a, b) => a.localeCompare(b))
.map((t) => ({ label: t, value: t }))
})
const dashboardCards = computed(() => {
const d = dashboard.value || {}
return [
{ key: 'threat_events_24h', label: '最近24小时威胁事件', value: normalizeCount(d.threat_events_24h) },
{ key: 'banned_ip_count', label: '当前封禁IP数', value: normalizeCount(d.banned_ip_count) },
{ key: 'banned_user_count', label: '当前封禁用户数', value: normalizeCount(d.banned_user_count) },
]
})
const threatTotalPages = computed(() => Math.max(1, Math.ceil((threatTotal.value || 0) / pageSize)))
async function loadDashboard() {
dashboardLoading.value = true
try {
dashboard.value = await getDashboard()
} catch {
dashboard.value = null
} finally {
dashboardLoading.value = false
}
}
async function loadThreats() {
threatsLoading.value = true
try {
const params = {
page: threatPage.value,
per_page: pageSize,
}
if (threatTypeFilter.value) params.event_type = threatTypeFilter.value
if (threatSeverityFilter.value) params.severity = threatSeverityFilter.value
const data = await getThreats(params)
threatItems.value = data?.items || []
threatTotal.value = data?.total || 0
} catch {
threatItems.value = []
threatTotal.value = 0
} finally {
threatsLoading.value = false
}
}
async function loadBans() {
if (bansLoading.value) return
bansLoading.value = true
try {
const [ipsRes, usersRes] = await Promise.allSettled([getBannedIps(), getBannedUsers()])
bannedIps.value = ipsRes.status === 'fulfilled' ? ipsRes.value?.items || [] : []
bannedUsers.value = usersRes.status === 'fulfilled' ? usersRes.value?.items || [] : []
} finally {
bansLoading.value = false
}
}
async function refreshAll() {
await Promise.allSettled([loadDashboard(), loadThreats(), loadBans()])
}
function onThreatFilter() {
threatPage.value = 1
loadThreats()
}
function onThreatReset() {
threatTypeFilter.value = ''
threatSeverityFilter.value = ''
threatPage.value = 1
loadThreats()
}
function resetBanForm() {
banForm.value = {
kind: 'ip',
ip: '',
user_id: '',
reason: '',
duration_hours: 24,
permanent: false,
}
}
function openBanDialog(kind = 'ip', preset = {}) {
resetBanForm()
banForm.value.kind = kind === 'user' ? 'user' : 'ip'
if (banForm.value.kind === 'ip') {
banForm.value.ip = String(preset.ip || '').trim()
} else {
banForm.value.user_id = String(preset.user_id || '').trim()
}
if (preset.reason) banForm.value.reason = String(preset.reason || '').trim()
banDialogOpen.value = true
}
async function submitBan() {
const kind = banForm.value.kind
const reason = String(banForm.value.reason || '').trim()
const permanent = Boolean(banForm.value.permanent)
const durationHours = Number(banForm.value.duration_hours || 24)
if (!reason) {
ElMessage.error('原因不能为空')
return
}
if (kind === 'ip') {
const ip = String(banForm.value.ip || '').trim()
if (!ip) {
ElMessage.error('IP不能为空')
return
}
banSubmitting.value = true
try {
await banIp({ ip, reason, duration_hours: durationHours, permanent })
ElMessage.success('IP已封禁')
banDialogOpen.value = false
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
} finally {
banSubmitting.value = false
}
return
}
const userIdRaw = String(banForm.value.user_id || '').trim()
const userId = Number.parseInt(userIdRaw, 10)
if (!Number.isFinite(userId)) {
ElMessage.error('用户ID无效')
return
}
banSubmitting.value = true
try {
await banUser({ user_id: userId, reason, duration_hours: durationHours, permanent })
ElMessage.success('用户已封禁')
banDialogOpen.value = false
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
} finally {
banSubmitting.value = false
}
}
async function onUnbanIp(ip) {
const ipText = String(ip || '').trim()
if (!ipText) return
try {
await ElMessageBox.confirm(`确定解除对 IP ${ipText} 的封禁吗?`, '解除封禁', {
confirmButtonText: '解除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbanIp(ipText)
ElMessage.success('已解除IP封禁')
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
}
}
async function onUnbanUser(userId) {
const id = Number.parseInt(String(userId || '').trim(), 10)
if (!Number.isFinite(id)) return
try {
await ElMessageBox.confirm(`确定解除对 用户ID ${id} 的封禁吗?`, '解除封禁', {
confirmButtonText: '解除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await unbanUser(id)
ElMessage.success('已解除用户封禁')
await Promise.allSettled([loadDashboard(), loadBans()])
} catch {
// handled by interceptor
}
}
function jumpToIpRisk(ip) {
const ipText = String(ip || '').trim()
if (!ipText) return
activeTab.value = 'risk'
riskTab.value = 'ip'
riskIpInput.value = ipText
queryIpRisk()
}
function jumpToUserRisk(userId) {
const idText = String(userId || '').trim()
if (!idText) return
activeTab.value = 'risk'
riskTab.value = 'user'
riskUserIdInput.value = idText
queryUserRisk()
}
async function queryIpRisk() {
const ip = String(riskIpInput.value || '').trim()
if (!ip) {
ElMessage.error('请输入IP')
return
}
riskLoading.value = true
try {
riskResult.value = await getIpRisk(ip)
riskResultKind.value = 'ip'
} catch {
riskResult.value = null
riskResultKind.value = ''
} finally {
riskLoading.value = false
}
}
async function queryUserRisk() {
const raw = String(riskUserIdInput.value || '').trim()
const userId = Number.parseInt(raw, 10)
if (!Number.isFinite(userId)) {
ElMessage.error('请输入有效的用户ID')
return
}
riskLoading.value = true
try {
riskResult.value = await getUserRisk(userId)
riskResultKind.value = 'user'
} catch {
riskResult.value = null
riskResultKind.value = ''
} finally {
riskLoading.value = false
}
}
function openBanFromRisk() {
if (!riskResult.value || !riskResultKind.value) return
if (riskResultKind.value === 'ip') {
openBanDialog('ip', { ip: riskResult.value?.ip, reason: '风险查询手动封禁' })
} else {
openBanDialog('user', { user_id: riskResult.value?.user_id, reason: '风险查询手动封禁' })
}
}
async function unbanFromRisk() {
if (!riskResult.value || !riskResultKind.value) return
if (riskResultKind.value === 'ip') {
await onUnbanIp(riskResult.value?.ip)
await queryIpRisk()
} else {
await onUnbanUser(riskResult.value?.user_id)
await queryUserRisk()
}
}
async function clearIpRiskScore() {
if (riskResultKind.value !== 'ip') return
const ipText = String(riskResult.value?.ip || '').trim()
if (!ipText) return
try {
await ElMessageBox.confirm(
`确定清除 IP ${ipText} 的风险分吗?\n\n清除风险分不会删除威胁历史也不会解除封禁。`,
'清除风险分',
{ confirmButtonText: '清除', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
if (riskLoading.value) return
riskLoading.value = true
try {
await clearIpRisk(ipText)
ElMessage.success('IP风险分已清零')
} catch {
// handled by interceptor
} finally {
riskLoading.value = false
}
await queryIpRisk()
}
const cleanupLoading = ref(false)
async function onCleanup() {
try {
await ElMessageBox.confirm(
'确定清理过期封禁记录,并衰减风险分吗?\n\n该操作不会影响仍在有效期内的封禁。',
'清理过期记录',
{ confirmButtonText: '清理', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
cleanupLoading.value = true
try {
await cleanup()
ElMessage.success('清理完成')
await refreshAll()
} catch {
// handled by interceptor
} finally {
cleanupLoading.value = false
}
}
onMounted(async () => {
await refreshAll()
})
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>安全防护</h2>
<div class="toolbar">
<el-button @click="refreshAll">刷新</el-button>
<el-button type="warning" plain :loading="cleanupLoading" @click="onCleanup">清理过期记录</el-button>
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
</div>
<el-row :gutter="12" class="stats-row">
<el-col v-for="it in dashboardCards" :key="it.key" :xs="24" :sm="8" :md="8" :lg="8" :xl="8">
<el-card shadow="never" class="stat-card" :body-style="{ padding: '14px' }">
<div class="stat-value">
<el-skeleton v-if="dashboardLoading" :rows="1" animated />
<template v-else>{{ it.value }}</template>
</div>
<div class="stat-label">{{ it.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<el-tabs v-model="activeTab">
<el-tab-pane label="威胁事件" name="threats">
<div class="filters">
<el-select
v-model="threatTypeFilter"
placeholder="类型"
style="width: 220px"
filterable
clearable
allow-create
default-first-option
>
<el-option label="全部" value="" />
<el-option v-for="t in threatTypeOptions" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
<el-select v-model="threatSeverityFilter" placeholder="严重程度" style="width: 200px" clearable>
<el-option label="全部" value="" />
<el-option label="高风险(>=80)" value="high" />
<el-option label="中风险(50-79)" value="medium" />
<el-option label="低风险(<50)" value="low" />
</el-select>
<el-button type="primary" @click="onThreatFilter">筛选</el-button>
<el-button @click="onThreatReset">重置</el-button>
</div>
<div class="table-wrap">
<el-table :data="threatItems" v-loading="threatsLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="类型" width="170">
<template #default="{ row }">
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="严重程度" width="120">
<template #default="{ row }">
<el-tag :type="scoreMeta(row.score).type" effect="light">
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
</el-tag>
</template>
</el-table-column>
<el-table-column label="IP" width="150">
<template #default="{ row }">
<el-link v-if="row.ip" type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
{{ row.ip }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="用户" width="120">
<template #default="{ row }">
<el-link
v-if="row.user_id !== null && row.user_id !== undefined"
type="primary"
:underline="false"
@click="jumpToUserRisk(row.user_id)"
>
{{ row.user_id }}
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作路径" min-width="220">
<template #default="{ row }">
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
<span class="mono ellipsis">{{ pathText(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="Payload预览" min-width="240">
<template #default="{ row }">
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
<div class="pagination">
<el-pagination
v-model:current-page="threatPage"
:page-size="pageSize"
:total="threatTotal"
layout="prev, pager, next, jumper, ->, total"
@current-change="loadThreats"
/>
<div class="page-hint app-muted"> {{ threatPage }} / {{ threatTotalPages }} </div>
</div>
</el-tab-pane>
<el-tab-pane label="封禁管理" name="bans">
<div class="toolbar">
<el-button @click="loadBans">刷新封禁列表</el-button>
<el-button type="primary" @click="openBanDialog()">手动封禁</el-button>
</div>
<el-tabs v-model="banTab" class="inner-tabs">
<el-tab-pane label="IP黑名单" name="ips">
<div class="table-wrap">
<el-table :data="bannedIps" v-loading="bansLoading" style="width: 100%">
<el-table-column label="IP" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="jumpToIpRisk(row.ip)">
{{ row.ip || '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="260" />
<el-table-column label="过期时间" width="190">
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onUnbanIp(row.ip)">解除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="用户黑名单" name="users">
<div class="table-wrap">
<el-table :data="bannedUsers" v-loading="bansLoading" style="width: 100%">
<el-table-column label="用户ID" width="180">
<template #default="{ row }">
<el-link type="primary" :underline="false" @click="jumpToUserRisk(row.user_id)">
{{ row.user_id ?? '-' }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="260" />
<el-table-column label="过期时间" width="190">
<template #default="{ row }">{{ formatExpires(row.expires_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" type="danger" plain @click="onUnbanUser(row.user_id)">解除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="风险查询" name="risk">
<el-tabs v-model="riskTab" class="inner-tabs">
<el-tab-pane label="IP查询" name="ip">
<div class="filters">
<el-input v-model="riskIpInput" placeholder="输入IP如 1.2.3.4" style="width: 260px" clearable />
<el-button type="primary" :loading="riskLoading" @click="queryIpRisk">查询</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="用户查询" name="user">
<div class="filters">
<el-input v-model="riskUserIdInput" placeholder="输入用户ID如 123" style="width: 260px" clearable />
<el-button type="primary" :loading="riskLoading" @click="queryUserRisk">查询</el-button>
</div>
</el-tab-pane>
</el-tabs>
<el-card v-if="riskResult" shadow="never" :body-style="{ padding: '16px' }" class="sub-card">
<div class="risk-head">
<div class="risk-title">
<strong v-if="riskResultKind === 'ip'">IP: {{ riskResult.ip }}</strong>
<strong v-else>用户ID: {{ riskResult.user_id }}</strong>
<span class="app-muted">风险分</span>
<el-tag :type="scoreMeta(riskResult.risk_score).type" effect="light">
{{ riskResult.risk_score ?? 0 }}
</el-tag>
<el-tag v-if="riskResult.is_banned" type="danger" effect="light">已封禁</el-tag>
<el-tag v-else type="success" effect="light">未封禁</el-tag>
</div>
<div class="toolbar">
<el-button v-if="!riskResult.is_banned" type="primary" plain @click="openBanFromRisk">封禁</el-button>
<el-button v-else type="danger" plain @click="unbanFromRisk">解除封禁</el-button>
<el-button
v-if="riskResultKind === 'ip'"
type="warning"
plain
:loading="riskLoading"
@click="clearIpRiskScore"
>
清除风险分
</el-button>
</div>
</div>
<div class="table-wrap">
<el-table :data="riskResult.threat_history || []" v-loading="riskLoading" style="width: 100%">
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="类型" width="170">
<template #default="{ row }">
<el-tag effect="light" type="info">{{ row.threat_type || 'unknown' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="严重程度" width="120">
<template #default="{ row }">
<el-tag :type="scoreMeta(row.score).type" effect="light">
{{ scoreMeta(row.score).label }} ({{ row.score ?? 0 }})
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作路径" min-width="220">
<template #default="{ row }">
<el-tooltip :content="pathText(row)" placement="top" :show-after="300">
<span class="mono ellipsis">{{ pathText(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="Payload预览" min-width="240">
<template #default="{ row }">
<el-tooltip :content="payloadTooltip(row)" placement="top" :show-after="300">
<span class="ellipsis">{{ row.value_preview || '-' }}</span>
</el-tooltip>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
</el-card>
<el-dialog v-model="banDialogOpen" title="手动封禁" width="min(520px, 92vw)" @closed="resetBanForm">
<el-form label-width="120px">
<el-form-item label="类型">
<el-radio-group v-model="banForm.kind">
<el-radio-button label="ip">IP</el-radio-button>
<el-radio-button label="user">用户</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="banForm.kind === 'ip'" label="IP">
<el-input v-model="banForm.ip" placeholder="例如 1.2.3.4" />
</el-form-item>
<el-form-item v-else label="用户ID">
<el-input v-model="banForm.user_id" placeholder="例如 123" />
</el-form-item>
<el-form-item label="原因">
<el-input v-model="banForm.reason" type="textarea" :rows="3" placeholder="请输入封禁原因" />
</el-form-item>
<el-form-item label="永久封禁">
<el-switch v-model="banForm.permanent" />
</el-form-item>
<el-form-item v-if="!banForm.permanent" label="持续(小时)">
<el-input-number v-model="banForm.duration_hours" :min="1" :max="8760" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-actions">
<div class="spacer"></div>
<el-button @click="banDialogOpen = false">取消</el-button>
<el-button type="primary" :loading="banSubmitting" @click="submitBan">确认封禁</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.stats-row {
margin-bottom: 2px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.sub-card {
margin-top: 12px;
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.stat-card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
box-shadow: var(--app-shadow);
}
.stat-value {
font-size: 22px;
font-weight: 800;
line-height: 1.1;
}
.stat-label {
margin-top: 6px;
font-size: 12px;
color: var(--app-muted);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 12px;
}
.table-wrap {
overflow-x: auto;
}
.ellipsis {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.page-hint {
font-size: 12px;
}
.inner-tabs {
margin-top: 6px;
}
.risk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.risk-title {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 10px;
}
.spacer {
flex: 1;
}
</style>

View File

@@ -8,6 +8,14 @@ const username = ref('')
const password = ref('')
const submitting = ref(false)
function validateStrongPassword(value) {
const text = String(value || '')
if (text.length < 8) return { ok: false, message: '密码长度至少8位' }
if (text.length > 128) return { ok: false, message: '密码长度不能超过128个字符' }
if (!/[a-zA-Z]/.test(text) || !/\d/.test(text)) return { ok: false, message: '密码必须包含字母和数字' }
return { ok: true, message: '' }
}
async function relogin() {
try {
await logout()
@@ -54,8 +62,9 @@ async function savePassword() {
ElMessage.error('请输入新密码')
return
}
if (value.length < 6) {
ElMessage.error('密码至少6个字符')
const check = validateStrongPassword(value)
if (!check.ok) {
ElMessage.error(check.message)
return
}

View File

@@ -1,10 +1,10 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchSystemConfig, updateSystemConfig, executeScheduleNow } from '../api/system'
import { fetchKdocsQr, fetchKdocsStatus, clearKdocsLogin } from '../api/kdocs'
import { fetchProxyConfig, testProxy, updateProxyConfig } from '../api/proxy'
import { fetchUpdateLog, fetchUpdateResult, fetchUpdateStatus, requestUpdateCheck, requestUpdateRun } from '../api/update'
const loading = ref(false)
@@ -18,6 +18,7 @@ const scheduleEnabled = ref(false)
const scheduleTime = ref('02:00')
const scheduleBrowseType = ref('应读')
const scheduleWeekdays = ref(['1', '2', '3', '4', '5', '6', '7'])
const scheduleScreenshotEnabled = ref(true)
// 代理
const proxyEnabled = ref(false)
@@ -29,16 +30,25 @@ const autoApproveEnabled = ref(false)
const autoApproveHourlyLimit = ref(10)
const autoApproveVipDays = ref(7)
// 自动更新
const updateLoading = ref(false)
const updateActionLoading = ref(false)
const updateStatus = ref(null)
const updateStatusError = ref('')
const updateResult = ref(null)
const updateLog = ref('')
const updateLogTruncated = ref(false)
const updateBuildNoCache = ref(false)
let updatePollTimer = null
// 金山文档上传
const kdocsEnabled = ref(false)
const kdocsDocUrl = ref('')
const kdocsDefaultUnit = ref('')
const kdocsSheetName = ref('')
const kdocsSheetIndex = ref(0)
const kdocsUnitColumn = ref('A')
const kdocsImageColumn = ref('D')
const kdocsAdminNotifyEnabled = ref(false)
const kdocsAdminNotifyEmail = ref('')
const kdocsStatus = ref({})
const kdocsQrOpen = ref(false)
const kdocsQrImage = ref('')
const kdocsPolling = ref(false)
const kdocsStatusLoading = ref(false)
const kdocsQrLoading = ref(false)
const kdocsClearLoading = ref(false)
const kdocsActionHint = ref('')
let kdocsPollingTimer = null
const weekdaysOptions = [
{ label: '周一', value: '1' },
@@ -65,69 +75,32 @@ const scheduleWeekdayDisplay = computed(() =>
.map((d) => weekdayNames[Number(d)] || d)
.join('、'),
)
const kdocsActionBusy = computed(
() => kdocsStatusLoading.value || kdocsQrLoading.value || kdocsClearLoading.value,
)
function normalizeBrowseType(value) {
if (String(value) === '注册前未读') return '注册前未读'
return '应读'
}
function shortCommit(value) {
const text = String(value || '').trim()
if (!text) return '-'
return text.length > 12 ? `${text.slice(0, 12)}` : text
}
async function loadUpdateInfo({ withLog = true } = {}) {
updateLoading.value = true
updateStatusError.value = ''
try {
const [statusRes, resultRes] = await Promise.all([fetchUpdateStatus(), fetchUpdateResult()])
if (statusRes?.ok) {
updateStatus.value = statusRes.data || null
} else {
updateStatus.value = null
updateStatusError.value = statusRes?.error || '未发现更新状态Update-Agent 可能未运行)'
}
updateResult.value = resultRes?.ok ? resultRes.data : null
const jobId = updateResult.value?.job_id
if (withLog && jobId) {
const logRes = await fetchUpdateLog({ job_id: jobId, max_bytes: 200000 })
updateLog.value = logRes?.log || ''
updateLogTruncated.value = !!logRes?.truncated
} else {
updateLog.value = ''
updateLogTruncated.value = false
}
} catch {
// handled by interceptor
} finally {
updateLoading.value = false
}
}
function startUpdatePolling() {
if (updatePollTimer) return
updatePollTimer = setInterval(async () => {
if (updateResult.value?.status === 'running') {
await loadUpdateInfo()
}
}, 5000)
}
function stopUpdatePolling() {
if (updatePollTimer) {
clearInterval(updatePollTimer)
updatePollTimer = null
function setKdocsHint(message) {
if (!message) {
kdocsActionHint.value = ''
return
}
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
kdocsActionHint.value = `${message} (${time})`
}
async function loadAll() {
loading.value = true
try {
const [system, proxy] = await Promise.all([fetchSystemConfig(), fetchProxyConfig()])
const [system, proxy, kdocsInfo] = await Promise.all([
fetchSystemConfig(),
fetchProxyConfig(),
fetchKdocsStatus().catch(() => ({})),
])
maxConcurrentGlobal.value = system.max_concurrent_global ?? 2
maxConcurrentPerAccount.value = system.max_concurrent_per_account ?? 1
@@ -142,6 +115,7 @@ async function loadAll() {
.map((x) => x.trim())
.filter(Boolean)
scheduleWeekdays.value = weekdays.length ? weekdays : ['1', '2', '3', '4', '5', '6', '7']
scheduleScreenshotEnabled.value = (system.enable_screenshot ?? 1) === 1
autoApproveEnabled.value = (system.auto_approve_enabled ?? 0) === 1
autoApproveHourlyLimit.value = system.auto_approve_hourly_limit ?? 10
@@ -151,8 +125,16 @@ async function loadAll() {
proxyApiUrl.value = proxy.proxy_api_url || ''
proxyExpireMinutes.value = proxy.proxy_expire_minutes ?? 3
await loadUpdateInfo({ withLog: false })
startUpdatePolling()
kdocsEnabled.value = (system.kdocs_enabled ?? 0) === 1
kdocsDocUrl.value = system.kdocs_doc_url || ''
kdocsDefaultUnit.value = system.kdocs_default_unit || ''
kdocsSheetName.value = system.kdocs_sheet_name || ''
kdocsSheetIndex.value = system.kdocs_sheet_index ?? 0
kdocsUnitColumn.value = (system.kdocs_unit_column || 'A').toUpperCase()
kdocsImageColumn.value = (system.kdocs_image_column || 'D').toUpperCase()
kdocsAdminNotifyEnabled.value = (system.kdocs_admin_notify_enabled ?? 0) === 1
kdocsAdminNotifyEmail.value = system.kdocs_admin_notify_email || ''
kdocsStatus.value = kdocsInfo || {}
} catch {
// handled by interceptor
} finally {
@@ -196,10 +178,12 @@ async function saveSchedule() {
schedule_time: scheduleTime.value,
schedule_browse_type: scheduleBrowseType.value,
schedule_weekdays: (scheduleWeekdays.value || []).join(','),
enable_screenshot: scheduleScreenshotEnabled.value ? 1 : 0,
}
const screenshotText = scheduleScreenshotEnabled.value ? '截图' : '不截图'
const message = scheduleEnabled.value
? `确定启用定时任务吗?\n\n执行时间: 每天 ${payload.schedule_time}\n执行日期: ${scheduleWeekdayDisplay.value}\n浏览类型: ${payload.schedule_browse_type}\n\n系统将自动执行所有账号的浏览任务(不包含截图)`
? `确定启用定时任务吗?\n\n执行时间: 每天 ${payload.schedule_time}\n执行日期: ${scheduleWeekdayDisplay.value}\n浏览类型: ${payload.schedule_browse_type}\n截图: ${screenshotText}\n\n系统将自动执行所有账号的浏览任务`
: '确定关闭定时任务吗?'
try {
@@ -260,6 +244,131 @@ async function saveProxy() {
}
}
async function saveKdocsConfig() {
const payload = {
kdocs_enabled: kdocsEnabled.value ? 1 : 0,
kdocs_doc_url: kdocsDocUrl.value.trim(),
kdocs_default_unit: kdocsDefaultUnit.value.trim(),
kdocs_sheet_name: kdocsSheetName.value.trim(),
kdocs_sheet_index: Number(kdocsSheetIndex.value) || 0,
kdocs_unit_column: kdocsUnitColumn.value.trim().toUpperCase(),
kdocs_image_column: kdocsImageColumn.value.trim().toUpperCase(),
kdocs_admin_notify_enabled: kdocsAdminNotifyEnabled.value ? 1 : 0,
kdocs_admin_notify_email: kdocsAdminNotifyEmail.value.trim(),
}
try {
const res = await updateSystemConfig(payload)
ElMessage.success(res?.message || '表格配置已更新')
} catch {
// handled by interceptor
}
}
async function refreshKdocsStatus() {
if (kdocsStatusLoading.value) return
kdocsStatusLoading.value = true
setKdocsHint('正在刷新状态')
try {
kdocsStatus.value = await fetchKdocsStatus({ live: 1 })
setKdocsHint('状态已刷新')
} catch {
setKdocsHint('刷新失败,请稍后重试')
} finally {
kdocsStatusLoading.value = false
}
}
async function pollKdocsStatus() {
try {
const status = await fetchKdocsStatus({ live: 1 })
kdocsStatus.value = status
const loggedIn = status?.logged_in === true || status?.last_login_ok === true
if (loggedIn) {
ElMessage.success('扫码成功,已登录')
setKdocsHint('扫码成功,已登录')
kdocsQrOpen.value = false
stopKdocsPolling()
}
} catch {
// handled by interceptor
}
}
function startKdocsPolling() {
stopKdocsPolling()
kdocsPolling.value = true
setKdocsHint('扫码检测中')
pollKdocsStatus()
kdocsPollingTimer = setInterval(pollKdocsStatus, 2000)
}
function stopKdocsPolling() {
if (kdocsPollingTimer) {
clearInterval(kdocsPollingTimer)
kdocsPollingTimer = null
}
kdocsPolling.value = false
}
async function onFetchKdocsQr() {
if (kdocsQrLoading.value) return
kdocsQrLoading.value = true
setKdocsHint('正在获取二维码')
try {
kdocsQrImage.value = ''
const res = await fetchKdocsQr()
kdocsQrImage.value = res?.qr_image || ''
if (!kdocsQrImage.value) {
if (res?.logged_in) {
ElMessage.success('当前已登录,无需扫码')
setKdocsHint('当前已登录,无需扫码')
await refreshKdocsStatus()
return
}
ElMessage.warning('未获取到二维码')
setKdocsHint('未获取到二维码')
return
}
setKdocsHint('二维码已获取')
kdocsQrOpen.value = true
} catch {
setKdocsHint('获取二维码失败')
} finally {
kdocsQrLoading.value = false
}
}
async function onClearKdocsLogin() {
if (kdocsClearLoading.value) return
kdocsClearLoading.value = true
setKdocsHint('正在清除登录态')
try {
await clearKdocsLogin()
kdocsQrOpen.value = false
kdocsQrImage.value = ''
ElMessage.success('登录态已清除')
setKdocsHint('登录态已清除')
await refreshKdocsStatus()
} catch {
setKdocsHint('清除登录态失败')
} finally {
kdocsClearLoading.value = false
}
}
watch(kdocsQrOpen, (open) => {
if (open) {
startKdocsPolling()
} else {
stopKdocsPolling()
}
})
onBeforeUnmount(() => {
stopKdocsPolling()
})
async function onTestProxy() {
if (!proxyApiUrl.value.trim()) {
ElMessage.error('请先输入代理API地址')
@@ -301,49 +410,7 @@ async function saveAutoApprove() {
}
}
async function onCheckUpdate() {
updateActionLoading.value = true
try {
const res = await requestUpdateCheck()
ElMessage.success(res?.success ? '已触发检查更新' : '已提交检查请求')
setTimeout(() => loadUpdateInfo({ withLog: false }), 800)
} catch {
// handled by interceptor
} finally {
updateActionLoading.value = false
}
}
async function onRunUpdate() {
const status = updateStatus.value
const remote = status?.remote_commit ? shortCommit(status.remote_commit) : '-'
const buildFlags = updateBuildNoCache.value ? '\n\n构建选项: 强制重建(--no-cache' : ''
try {
await ElMessageBox.confirm(
`确定开始“一键更新”吗?\n\n目标版本: ${remote}${buildFlags}\n\n更新将会重建并重启服务页面可能短暂不可用系统会先备份数据库。`,
'一键更新确认',
{ confirmButtonText: '开始更新', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
updateActionLoading.value = true
try {
const res = await requestUpdateRun({ build_no_cache: updateBuildNoCache.value ? 1 : 0 })
ElMessage.success(res?.message || '已提交更新请求')
startUpdatePolling()
setTimeout(() => loadUpdateInfo(), 800)
} catch {
// handled by interceptor
} finally {
updateActionLoading.value = false
}
}
onMounted(loadAll)
onBeforeUnmount(stopUpdatePolling)
</script>
<template>
@@ -371,7 +438,7 @@ onBeforeUnmount(stopUpdatePolling)
<el-form-item label="截图最大并发数">
<el-input-number v-model="maxScreenshotConcurrent" :min="1" :max="50" />
<div class="help">同时进行截图的最大数量每个浏览器约占用 200MB 内存</div>
<div class="help">同时进行截图的最大数量wkhtmltoimage 资源占用较低可按需提高</div>
</el-form-item>
</el-form>
@@ -384,6 +451,7 @@ onBeforeUnmount(stopUpdatePolling)
<el-form label-width="130px">
<el-form-item label="启用定时任务">
<el-switch v-model="scheduleEnabled" />
<div class="help">开启后系统会按计划自动执行浏览任务</div>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="执行时间">
@@ -404,6 +472,11 @@ onBeforeUnmount(stopUpdatePolling)
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="scheduleEnabled" label="定时任务截图">
<el-switch v-model="scheduleScreenshotEnabled" />
<div class="help">开启后定时任务执行时会生成截图</div>
</el-form-item>
</el-form>
<div class="row-actions">
@@ -458,88 +531,95 @@ onBeforeUnmount(stopUpdatePolling)
<el-button type="primary" @click="saveAutoApprove">保存注册设置</el-button>
</el-card>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card" v-loading="updateLoading">
<h3 class="section-title">版本与更新</h3>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<h3 class="section-title">金山文档上传</h3>
<el-alert
v-if="updateStatus?.update_available"
type="warning"
:closable="false"
title="检测到新版本:可以在此页面点击“一键更新”升级并自动重启服务。"
style="margin-bottom: 10px"
/>
<el-form label-width="130px">
<el-form-item label="启用上传">
<el-switch v-model="kdocsEnabled" />
<div class="help">表格结构变化时可先关闭避免错误上传</div>
</el-form-item>
<el-alert
v-if="updateStatusError"
type="info"
:closable="false"
:title="updateStatusError"
style="margin-bottom: 10px"
/>
<el-form-item label="文档链接">
<el-input v-model="kdocsDocUrl" placeholder="https://kdocs.cn/..." />
</el-form-item>
<el-descriptions border :column="1" size="small" style="margin-bottom: 10px">
<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="是否有更新">
<el-tag v-if="updateStatus?.update_available" type="danger"></el-tag>
<el-tag v-else type="success"></el-tag>
</el-descriptions-item>
<el-descriptions-item label="工作区修改">
<el-tag v-if="updateStatus?.dirty" type="warning">有未提交修改</el-tag>
<el-tag v-else type="info">干净</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近检查时间">
{{ updateStatus?.checked_at || '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="updateStatus?.error" label="检查错误">
{{ updateStatus?.error }}
</el-descriptions-item>
</el-descriptions>
<el-form-item label="默认县区">
<el-input v-model="kdocsDefaultUnit" placeholder="如:道县(用户可覆盖)" />
</el-form-item>
<div class="row-actions" style="align-items: center">
<el-checkbox v-model="updateBuildNoCache">强制重建--no-cache</el-checkbox>
<div class="help" style="margin-top: 0">依赖变更或构建异常时建议开启更新会更慢</div>
</div>
<el-form-item label="Sheet名称">
<el-input v-model="kdocsSheetName" placeholder="留空使用第一个Sheet" />
</el-form-item>
<el-form-item label="Sheet序号">
<el-input-number v-model="kdocsSheetIndex" :min="0" :max="50" />
<div class="help">0 表示第一个Sheet</div>
</el-form-item>
<el-form-item label="县区列">
<el-input v-model="kdocsUnitColumn" placeholder="A" style="max-width: 120px" />
</el-form-item>
<el-form-item label="图片列">
<el-input v-model="kdocsImageColumn" placeholder="D" style="max-width: 120px" />
</el-form-item>
<el-form-item label="管理员通知">
<el-switch v-model="kdocsAdminNotifyEnabled" />
</el-form-item>
<el-form-item label="通知邮箱">
<el-input v-model="kdocsAdminNotifyEmail" placeholder="admin@example.com" />
</el-form-item>
</el-form>
<div class="row-actions">
<el-button @click="loadUpdateInfo" :disabled="updateActionLoading">刷新更新信息</el-button>
<el-button @click="onCheckUpdate" :loading="updateActionLoading">检查更新</el-button>
<el-button type="danger" @click="onRunUpdate" :loading="updateActionLoading" :disabled="!updateStatus?.update_available">
一键更新
<el-button type="primary" @click="saveKdocsConfig">保存表格配置</el-button>
<el-button
:loading="kdocsStatusLoading"
:disabled="kdocsActionBusy && !kdocsStatusLoading"
@click="refreshKdocsStatus"
>
刷新状态
</el-button>
<el-button
type="success"
plain
:loading="kdocsQrLoading"
:disabled="kdocsActionBusy && !kdocsQrLoading"
@click="onFetchKdocsQr"
>
获取二维码
</el-button>
<el-button
type="danger"
plain
:loading="kdocsClearLoading"
:disabled="kdocsActionBusy && !kdocsClearLoading"
@click="onClearKdocsLogin"
>
清除登录
</el-button>
</div>
<el-divider content-position="left">最近一次更新结果</el-divider>
<el-descriptions v-if="updateResult" border :column="1" size="small" style="margin-bottom: 10px">
<el-descriptions-item label="job_id">{{ updateResult.job_id }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="updateResult.status === 'running'" type="warning">运行中</el-tag>
<el-tag v-else-if="updateResult.status === 'success'" type="success">成功</el-tag>
<el-tag v-else type="danger">失败</el-tag>
</el-descriptions-item>
<el-descriptions-item label="阶段">{{ updateResult.stage || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ updateResult.started_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="结束时间">{{ updateResult.finished_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="耗时(秒)">{{ updateResult.duration_seconds ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="更新前(commit)">{{ shortCommit(updateResult.from_commit) }}</el-descriptions-item>
<el-descriptions-item label="更新后(commit)">{{ shortCommit(updateResult.to_commit) }}</el-descriptions-item>
<el-descriptions-item label="健康检查">
<span v-if="updateResult.health_ok === true">通过{{ updateResult.health_message }}</span>
<span v-else-if="updateResult.health_ok === false">失败{{ updateResult.health_message }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item v-if="updateResult.error" label="错误">{{ updateResult.error }}</el-descriptions-item>
</el-descriptions>
<div v-else class="help">暂无更新记录</div>
<el-divider content-position="left">更新日志</el-divider>
<div class="help" v-if="updateLogTruncated">日志过长仅展示末尾内容</div>
<el-input v-model="updateLog" type="textarea" :rows="10" readonly placeholder="暂无日志" />
<div class="help">
登录状态
<span v-if="kdocsStatus.last_login_ok === true">已登录</span>
<span v-else-if="kdocsStatus.login_required">需要扫码</span>
<span v-else>未知</span>
· 待上传 {{ kdocsStatus.queue_size || 0 }}
<span v-if="kdocsStatus.last_error">· 最近错误{{ kdocsStatus.last_error }}</span>
</div>
<div v-if="kdocsActionHint" class="help">操作提示{{ kdocsActionHint }}</div>
</el-card>
<el-dialog v-model="kdocsQrOpen" title="扫码登录" width="min(420px, 92vw)">
<div class="kdocs-qr">
<img v-if="kdocsQrImage" :src="`data:image/png;base64,${kdocsQrImage}`" alt="KDocs QR" />
<div class="help">请使用管理员微信扫码登录</div>
</div>
</el-dialog>
</div>
</template>
@@ -561,6 +641,22 @@ onBeforeUnmount(stopUpdatePolling)
font-weight: 800;
}
.kdocs-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.kdocs-qr img {
width: 260px;
max-width: 100%;
border: 1px solid var(--app-border);
border-radius: 8px;
padding: 8px;
background: #fff;
}
.help {
margin-top: 6px;
font-size: 12px;

View File

@@ -11,19 +11,14 @@ import {
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
@@ -58,21 +53,8 @@ async function loadUsers() {
}
}
async function loadResets() {
resetLoading.value = true
try {
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 })
await loadUsers()
}
async function onEnableUser(row) {
@@ -117,48 +99,6 @@ async function onDisableUser(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(
@@ -338,27 +278,6 @@ onMounted(refreshAll)
</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>

View File

@@ -8,6 +8,7 @@ const FeedbacksPage = () => import('../pages/FeedbacksPage.vue')
const LogsPage = () => import('../pages/LogsPage.vue')
const AnnouncementsPage = () => import('../pages/AnnouncementsPage.vue')
const EmailPage = () => import('../pages/EmailPage.vue')
const SecurityPage = () => import('../pages/SecurityPage.vue')
const SystemPage = () => import('../pages/SystemPage.vue')
const SettingsPage = () => import('../pages/SettingsPage.vue')
@@ -25,6 +26,7 @@ const routes = [
{ path: '/logs', name: 'logs', component: LogsPage },
{ path: '/announcements', name: 'announcements', component: AnnouncementsPage },
{ path: '/email', name: 'email', component: EmailPage },
{ path: '/security', name: 'security', component: SecurityPage },
{ path: '/system', name: 'system', component: SystemPage },
{ path: '/settings', name: 'settings', component: SettingsPage },
],