Files
zsglpt/admin-frontend/src/pages/UsersPage.vue
yuyx 89f3fd9759 feat: 安全增强 + 删除密码重置申请功能 + 登录提醒开关
安全增强:
- 新增 SSRF、XXE、模板注入、敏感路径探测检测规则
- security/constants.py: 添加新的威胁类型和检测模式
- security/threat_detector.py: 实现新检测逻辑

删除密码重置申请功能:
- 移除 /api/password_resets 相关API
- 删除 password_reset_requests 数据库表
- 前端移除密码重置申请页面和菜单
- 用户只能通过邮��找回密码,未绑定邮箱需联系管理员

登录提醒全局开关:
- email_service.py: 添加 login_alert_enabled 字段
- routes/api_auth.py: 检查开关状态再发送登录提醒
- EmailPage.vue: 添加新设备登录提醒开关

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:08:36 +08:00

339 lines
8.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { inject, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
adminResetUserPassword,
deleteUser,
fetchAllUsers,
approveUser,
rejectUser,
removeUserVip,
setUserVip,
} from '../api/users'
import { parseSqliteDateTime } from '../utils/datetime'
import { validatePasswordStrength } from '../utils/password'
const refreshStats = inject('refreshStats', null)
const loading = ref(false)
const users = ref([])
function isVip(user) {
const expire = user?.vip_expire_time
if (!expire) return false
if (String(expire).startsWith('2099-12-31')) return true
const dt = parseSqliteDateTime(expire)
return dt ? dt.getTime() > Date.now() : false
}
function vipLabel(user) {
const expire = user?.vip_expire_time
if (!expire || !isVip(user)) return ''
if (String(expire).startsWith('2099-12-31')) return '永久VIP'
const dt = parseSqliteDateTime(expire)
if (!dt) return `到期: ${expire}`
const daysLeft = Math.ceil((dt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return `到期: ${expire}(剩${daysLeft}天)`
}
function statusMeta(status) {
if (status === 'rejected') return { label: '禁用', type: 'danger' }
return { label: '正常', type: 'success' }
}
async function loadUsers() {
loading.value = true
try {
users.value = await fetchAllUsers()
} catch {
users.value = []
} finally {
loading.value = false
}
}
async function refreshAll() {
await loadUsers()
}
async function onEnableUser(row) {
try {
await ElMessageBox.confirm(`确定启用用户「${row.username}」吗?启用后用户可正常登录。`, '启用用户', {
confirmButtonText: '启用',
cancelButtonText: '取消',
type: 'success',
})
} catch {
return
}
try {
await approveUser(row.id)
ElMessage.success('用户已启用')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onDisableUser(row) {
try {
await ElMessageBox.confirm(`确定禁用用户「${row.username}」吗?禁用后用户将无法登录。`, '禁用用户', {
confirmButtonText: '禁用',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
await rejectUser(row.id)
ElMessage.success('用户已禁用')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除用户「${row.username}」吗?此操作将删除该用户的所有数据,不可恢复!`,
'删除用户',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'error' },
)
} catch {
return
}
try {
await deleteUser(row.id)
ElMessage.success('用户已删除')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onSetVip(row, days) {
const label = { 7: '一周', 30: '一个月', 365: '一年', 999999: '永久' }[days] || `${days}`
try {
await ElMessageBox.confirm(`确定为用户「${row.username}」开通 ${label} VIP 吗?`, '设置VIP', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await setUserVip(row.id, days)
ElMessage.success(res?.message || 'VIP设置成功')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onRemoveVip(row) {
try {
await ElMessageBox.confirm(`确定移除用户「${row.username}」的 VIP 吗?`, '移除VIP', {
confirmButtonText: '移除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await removeUserVip(row.id)
ElMessage.success(res?.message || 'VIP已移除')
await loadUsers()
await refreshStats?.()
} catch {
// handled by interceptor
}
}
async function onResetPassword(row) {
let value
try {
const result = await ElMessageBox.prompt('请输入新密码至少8位且包含字母和数字', '重置密码', {
confirmButtonText: '提交',
cancelButtonText: '取消',
inputType: 'password',
inputPlaceholder: '新密码',
inputValidator: (v) => validatePasswordStrength(v).ok,
inputErrorMessage: '密码至少8位且包含字母和数字',
})
value = result.value
} catch {
return
}
const check = validatePasswordStrength(value)
if (!check.ok) {
ElMessage.error(check.message)
return
}
try {
await ElMessageBox.confirm(`确定将用户「${row.username}」的密码重置为该新密码吗?`, '二次确认', {
confirmButtonText: '确认重置',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
try {
const res = await adminResetUserPassword(row.id, value)
ElMessage.success(res?.message || '密码重置成功')
} catch {
// handled by interceptor
}
}
onMounted(refreshAll)
</script>
<template>
<div class="page-stack">
<div class="app-page-title">
<h2>用户</h2>
<div>
<el-button @click="refreshAll">刷新</el-button>
</div>
</div>
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
<div class="table-wrap">
<el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="240">
<template #default="{ row }">
<div class="user-block">
<div class="user-main">
<strong>{{ row.username }}</strong>
<el-tag v-if="isVip(row)" type="warning" effect="light" size="small">VIP</el-tag>
</div>
<div v-if="row.email" class="app-muted user-sub">{{ row.email }}</div>
<div v-if="vipLabel(row)" class="vip-sub">{{ vipLabel(row) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
<div>{{ row.created_at }}</div>
<div v-if="row.vip_expire_time" class="app-muted">VIP到期: {{ row.vip_expire_time }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div class="actions">
<el-button
v-if="row.status === 'rejected'"
type="success"
size="small"
@click="onEnableUser(row)"
>启用</el-button>
<el-button v-else type="warning" size="small" @click="onDisableUser(row)">禁用</el-button>
<el-dropdown trigger="click">
<el-button size="small">VIP</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 7)">开通一周</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 30)">开通一月</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 365)">开通一年</el-dropdown-item>
<el-dropdown-item v-if="!isVip(row)" @click="onSetVip(row, 999999)">永久VIP</el-dropdown-item>
<el-dropdown-item v-if="isVip(row)" @click="onRemoveVip(row)">移除VIP</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" @click="onResetPassword(row)">重置密码</el-button>
<el-button type="danger" size="small" @click="onDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.card {
border-radius: var(--app-radius);
border: 1px solid var(--app-border);
}
.section-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 800;
}
.help {
margin-top: 10px;
font-size: 12px;
}
.table-wrap {
overflow-x: auto;
}
.user-block {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-main {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-sub {
font-size: 12px;
}
.vip-sub {
font-size: 12px;
color: #7c3aed;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>