feat: add admin management, change password, and operation log

- Change password: current user can change their own password
- Admin management: superuser can create/toggle/reset-password for admins
- Operation log: view all system operations with type filter
- All operations are recorded to AlertRecord for audit trail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-20 18:20:14 +08:00
parent a7e030dc57
commit cbc19a6d9e
6 changed files with 469 additions and 1 deletions

View File

@ -10,3 +10,17 @@ class UserInfoSerializer(serializers.Serializer):
id = serializers.IntegerField() id = serializers.IntegerField()
username = serializers.CharField() username = serializers.CharField()
is_superuser = serializers.BooleanField() is_superuser = serializers.BooleanField()
is_active = serializers.BooleanField()
date_joined = serializers.DateTimeField()
last_login = serializers.DateTimeField()
class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(write_only=True)
new_password = serializers.CharField(write_only=True, min_length=6)
class AdminUserCreateSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(write_only=True, min_length=6)
is_superuser = serializers.BooleanField(default=False)

View File

@ -5,4 +5,9 @@ urlpatterns = [
path('login/', views.login_view), path('login/', views.login_view),
path('refresh/', views.refresh_view), path('refresh/', views.refresh_view),
path('me/', views.me_view), path('me/', views.me_view),
path('change-password/', views.change_password_view),
path('admins/', views.admin_list_view),
path('admins/create/', views.admin_create_view),
path('admins/<int:pk>/toggle/', views.admin_toggle_view),
path('admins/<int:pk>/reset-password/', views.admin_reset_password_view),
] ]

View File

@ -5,7 +5,11 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from .serializers import LoginSerializer, UserInfoSerializer from .models import AdminUser
from .serializers import (
LoginSerializer, UserInfoSerializer,
ChangePasswordSerializer, AdminUserCreateSerializer,
)
@api_view(['POST']) @api_view(['POST'])
@ -58,3 +62,136 @@ def refresh_view(request):
@api_view(['GET']) @api_view(['GET'])
def me_view(request): def me_view(request):
return Response(UserInfoSerializer(request.user).data) return Response(UserInfoSerializer(request.user).data)
@api_view(['POST'])
def change_password_view(request):
"""修改当前用户密码"""
serializer = ChangePasswordSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
if not request.user.check_password(serializer.validated_data['old_password']):
return Response({'error': 'wrong_password', 'message': '原密码错误'},
status=status.HTTP_400_BAD_REQUEST)
request.user.set_password(serializer.validated_data['new_password'])
request.user.save()
# Log operation
from apps.monitor.models import AlertRecord
AlertRecord.objects.create(
alert_type=AlertRecord.AlertType.MANUAL,
title=f"管理员 {request.user.username} 修改密码",
content=f"操作人: {request.user.username}",
)
return Response({'message': '密码修改成功,请重新登录'})
# ==================== Admin User Management ====================
@api_view(['GET'])
def admin_list_view(request):
"""列出所有管理员"""
if not request.user.is_superuser:
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
status=status.HTTP_403_FORBIDDEN)
users = AdminUser.objects.all().order_by('id')
return Response(UserInfoSerializer(users, many=True).data)
@api_view(['POST'])
def admin_create_view(request):
"""创建管理员账号"""
if not request.user.is_superuser:
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
status=status.HTTP_403_FORBIDDEN)
serializer = AdminUserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
d = serializer.validated_data
if AdminUser.objects.filter(username=d['username']).exists():
return Response({'error': 'user_exists', 'message': f'用户名 {d["username"]} 已存在'},
status=status.HTTP_409_CONFLICT)
user = AdminUser.objects.create_user(
username=d['username'],
password=d['password'],
is_superuser=d.get('is_superuser', False),
is_staff=True,
)
from apps.monitor.models import AlertRecord
AlertRecord.objects.create(
alert_type=AlertRecord.AlertType.MANUAL,
title=f"创建管理员 {d['username']}",
content=f"操作人: {request.user.username},超级管理员: {'' if d.get('is_superuser') else ''}",
)
return Response({
'message': f'管理员 {d["username"]} 创建成功',
'user': UserInfoSerializer(user).data,
}, status=status.HTTP_201_CREATED)
@api_view(['POST'])
def admin_toggle_view(request, pk):
"""启用/停用管理员"""
if not request.user.is_superuser:
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
status=status.HTTP_403_FORBIDDEN)
try:
user = AdminUser.objects.get(pk=pk)
except AdminUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
if user.pk == request.user.pk:
return Response({'error': 'self_toggle', 'message': '不能停用自己'},
status=status.HTTP_400_BAD_REQUEST)
user.is_active = not user.is_active
user.save(update_fields=['is_active'])
action = '启用' if user.is_active else '停用'
from apps.monitor.models import AlertRecord
AlertRecord.objects.create(
alert_type=AlertRecord.AlertType.MANUAL,
title=f"{action}管理员 {user.username}",
content=f"操作人: {request.user.username}",
)
return Response({'message': f'{action}管理员 {user.username}',
'user': UserInfoSerializer(user).data})
@api_view(['POST'])
def admin_reset_password_view(request, pk):
"""超管重置其他管理员密码"""
if not request.user.is_superuser:
return Response({'error': 'forbidden', 'message': '仅超级管理员可操作'},
status=status.HTTP_403_FORBIDDEN)
try:
user = AdminUser.objects.get(pk=pk)
except AdminUser.DoesNotExist:
return Response({'error': 'not_found'}, status=status.HTTP_404_NOT_FOUND)
new_password = request.data.get('new_password', '')
if len(new_password) < 6:
return Response({'error': 'weak_password', 'message': '密码至少6位'},
status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
from apps.monitor.models import AlertRecord
AlertRecord.objects.create(
alert_type=AlertRecord.AlertType.MANUAL,
title=f"重置管理员 {user.username} 密码",
content=f"操作人: {request.user.username}",
)
return Response({'message': f'已重置 {user.username} 的密码'})

View File

@ -24,6 +24,10 @@
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>系统设置</span> <span>系统设置</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/admin">
<el-icon><Key /></el-icon>
<span>系统管理</span>
</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>

View File

@ -17,6 +17,7 @@ const routes = [
{ path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') }, { path: 'billing', name: 'Billing', component: () => import('../views/billing/BillingView.vue') },
{ path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') }, { path: 'alerts', name: 'Alerts', component: () => import('../views/alerts/AlertList.vue') },
{ path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') }, { path: 'settings', name: 'Settings', component: () => import('../views/settings/SettingsView.vue') },
{ path: 'admin', name: 'Admin', component: () => import('../views/admin/AdminView.vue') },
], ],
}, },
] ]

View File

@ -0,0 +1,307 @@
<template>
<div style="max-width: 1400px; margin: 0 auto;">
<el-tabs v-model="activeTab">
<!-- Tab 1: Change Password -->
<el-tab-pane label="修改密码" name="password">
<el-card style="max-width: 500px;">
<template #header>修改密码</template>
<el-form label-width="100px">
<el-form-item label="原密码">
<el-input v-model="pwdForm.old_password" type="password" show-password />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.new_password" type="password" show-password
placeholder="至少6位" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input v-model="pwdForm.confirm_password" type="password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleChangePassword" :loading="pwdLoading">
修改密码
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<!-- Tab 2: Admin Management -->
<el-tab-pane label="管理员管理" name="admins" v-if="auth.user?.is_superuser">
<div style="margin-bottom: 16px;">
<el-button type="primary" @click="showCreateAdmin = true">创建管理员</el-button>
</div>
<el-table :data="admins" stripe v-loading="adminsLoading" style="width: 100%;">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="150" />
<el-table-column label="角色" min-width="120">
<template #default="{ row }">
<el-tag :type="row.is_superuser ? 'danger' : 'info'" size="small">
{{ row.is_superuser ? '超级管理员' : '普通管理员' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
{{ row.is_active ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最近登录" min-width="180">
<template #default="{ row }">
{{ row.last_login ? new Date(row.last_login).toLocaleString('zh-CN') : '从未登录' }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="200">
<template #default="{ row }">
<template v-if="row.id !== auth.user?.id">
<el-button size="small" text @click="handleToggleAdmin(row)">
{{ row.is_active ? '停用' : '启用' }}
</el-button>
<el-button size="small" text type="warning" @click="openResetPwd(row)">
重置密码
</el-button>
</template>
<span v-else style="color: #999; font-size: 12px;">当前账号</span>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Tab 3: Operation Log -->
<el-tab-pane label="操作日志" name="logs">
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
<el-select v-model="logFilter" placeholder="全部类型" clearable style="width: 160px;">
<el-option label="手动操作" value="manual" />
<el-option label="告警" value="warning" />
<el-option label="自动停用" value="disable" />
<el-option label="错误" value="error" />
</el-select>
<el-input-number v-model="logLimit" :min="10" :max="500" :step="50"
style="width: 140px;" />
<el-button @click="loadLogs" :loading="logsLoading">刷新</el-button>
</div>
<el-table :data="logs" stripe v-loading="logsLoading" style="width: 100%;"
empty-text="暂无日志记录">
<el-table-column label="时间" width="180">
<template #default="{ row }">
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="logTagType(row.alert_type)" size="small">
{{ logTypeLabel(row.alert_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联用户" width="120">
<template #default="{ row }">
{{ row.iam_user_username || '-' }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="250" />
<el-table-column prop="content" label="详情" min-width="300" show-overflow-tooltip />
</el-table>
</el-tab-pane>
</el-tabs>
<!-- Create Admin Dialog -->
<el-dialog v-model="showCreateAdmin" title="创建管理员" width="90%" style="max-width: 450px;">
<el-form label-width="100px">
<el-form-item label="用户名">
<el-input v-model="createAdminForm.username" placeholder="英文字母开头" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="createAdminForm.password" type="password" show-password
placeholder="至少6位" />
</el-form-item>
<el-form-item label="超级管理员">
<el-switch v-model="createAdminForm.is_superuser" />
<span style="margin-left: 8px; font-size: 12px; color: #999;">
超级管理员可以管理其他管理员账号
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreateAdmin = false">取消</el-button>
<el-button type="primary" @click="handleCreateAdmin" :loading="createAdminLoading">创建</el-button>
</template>
</el-dialog>
<!-- Reset Password Dialog -->
<el-dialog v-model="showResetPwd" title="重置密码" width="90%" style="max-width: 400px;">
<p style="margin-bottom: 12px; color: #606266;">
重置 <strong>{{ resetPwdUser?.username }}</strong> 的密码
</p>
<el-input v-model="resetPwdValue" type="password" show-password
placeholder="输入新密码至少6位" />
<template #footer>
<el-button @click="showResetPwd = false">取消</el-button>
<el-button type="primary" @click="handleResetPwd" :loading="resetPwdLoading">确认重置</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const activeTab = ref('password')
// === Change Password ===
const pwdForm = ref({ old_password: '', new_password: '', confirm_password: '' })
const pwdLoading = ref(false)
async function handleChangePassword() {
if (!pwdForm.value.old_password || !pwdForm.value.new_password) {
ElMessage.warning('请填写完整')
return
}
if (pwdForm.value.new_password !== pwdForm.value.confirm_password) {
ElMessage.warning('两次密码不一致')
return
}
if (pwdForm.value.new_password.length < 6) {
ElMessage.warning('密码至少6位')
return
}
pwdLoading.value = true
try {
const { data } = await api.post('/api/v1/auth/change-password/', {
old_password: pwdForm.value.old_password,
new_password: pwdForm.value.new_password,
})
ElMessage.success(data.message)
pwdForm.value = { old_password: '', new_password: '', confirm_password: '' }
// Force re-login
setTimeout(() => {
auth.logout()
router.push('/login')
}, 1500)
} catch (e) {
ElMessage.error(e.response?.data?.message || '修改失败')
} finally {
pwdLoading.value = false
}
}
// === Admin Management ===
const admins = ref([])
const adminsLoading = ref(false)
const showCreateAdmin = ref(false)
const createAdminForm = ref({ username: '', password: '', is_superuser: false })
const createAdminLoading = ref(false)
const showResetPwd = ref(false)
const resetPwdUser = ref(null)
const resetPwdValue = ref('')
const resetPwdLoading = ref(false)
async function loadAdmins() {
if (!auth.user?.is_superuser) return
adminsLoading.value = true
try {
const { data } = await api.get('/api/v1/auth/admins/')
admins.value = data
} catch (e) {
admins.value = []
} finally {
adminsLoading.value = false
}
}
async function handleCreateAdmin() {
if (!createAdminForm.value.username || !createAdminForm.value.password) {
ElMessage.warning('请填写完整')
return
}
createAdminLoading.value = true
try {
const { data } = await api.post('/api/v1/auth/admins/create/', createAdminForm.value)
ElMessage.success(data.message)
showCreateAdmin.value = false
createAdminForm.value = { username: '', password: '', is_superuser: false }
await loadAdmins()
} catch (e) {
ElMessage.error(e.response?.data?.message || '创建失败')
} finally {
createAdminLoading.value = false
}
}
async function handleToggleAdmin(row) {
try {
const { data } = await api.post(`/api/v1/auth/admins/${row.id}/toggle/`)
ElMessage.success(data.message)
await loadAdmins()
} catch (e) {
ElMessage.error(e.response?.data?.message || '操作失败')
}
}
function openResetPwd(row) {
resetPwdUser.value = row
resetPwdValue.value = ''
showResetPwd.value = true
}
async function handleResetPwd() {
if (resetPwdValue.value.length < 6) {
ElMessage.warning('密码至少6位')
return
}
resetPwdLoading.value = true
try {
const { data } = await api.post(`/api/v1/auth/admins/${resetPwdUser.value.id}/reset-password/`, {
new_password: resetPwdValue.value,
})
ElMessage.success(data.message)
showResetPwd.value = false
} catch (e) {
ElMessage.error(e.response?.data?.message || '重置失败')
} finally {
resetPwdLoading.value = false
}
}
// === Operation Logs ===
const logs = ref([])
const logsLoading = ref(false)
const logFilter = ref('')
const logLimit = ref(100)
async function loadLogs() {
logsLoading.value = true
try {
const params = { limit: logLimit.value }
if (logFilter.value) params.type = logFilter.value
const { data } = await api.get('/api/v1/alerts/', { params })
logs.value = data
} catch (e) {
logs.value = []
} finally {
logsLoading.value = false
}
}
function logTagType(type) {
const map = { warning: 'warning', disable: 'danger', error: 'danger', manual: 'info' }
return map[type] || 'info'
}
function logTypeLabel(type) {
const map = { warning: '告警', disable: '自动停用', error: '错误', manual: '操作' }
return map[type] || type
}
onMounted(() => {
loadAdmins()
loadLogs()
})
</script>