feat: 添加安全模块 + Dockerfile添加curl支持健康检查
主要更新: - 新增 security/ 安全模块 (风险评估、威胁检测、蜜罐等) - Dockerfile 添加 curl 以支持 Docker 健康检查 - 前端页面更新 (管理后台、用户端) - 数据库迁移和 schema 更新 - 新增 kdocs 上传服务 - 添加安全相关测试用例 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
7
admin-frontend/src/api/browser_pool.js
Normal file
7
admin-frontend/src/api/browser_pool.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { api } from './client'
|
||||
|
||||
export async function fetchBrowserPoolStats() {
|
||||
const { data } = await api.get('/browser_pool/stats')
|
||||
return data
|
||||
}
|
||||
|
||||
17
admin-frontend/src/api/kdocs.js
Normal file
17
admin-frontend/src/api/kdocs.js
Normal 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
|
||||
}
|
||||
63
admin-frontend/src/api/security.js
Normal file
63
admin-frontend/src/api/security.js
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
843
admin-frontend/src/pages/SecurityPage.vue
Normal file
843
admin-frontend/src/pages/SecurityPage.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user