新增用户管理和个人信息功能,支持CRUD操作和密码修改
This commit is contained in:
+20
-2
@@ -36,13 +36,31 @@ const navLinks = [
|
||||
</RouterLink>
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<RouterLink
|
||||
to="/admin"
|
||||
to="/profile"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
hover:bg-sky-500/40"
|
||||
active-class="bg-sky-700/60"
|
||||
>
|
||||
管理后台
|
||||
用户信息
|
||||
</RouterLink>
|
||||
<template v-if="authStore.user?.role === 'admin'">
|
||||
<RouterLink
|
||||
to="/admin"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
hover:bg-sky-500/40"
|
||||
active-class="bg-sky-700/60"
|
||||
>
|
||||
管理后台
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/admin/users"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
hover:bg-sky-500/40"
|
||||
active-class="bg-sky-700/60"
|
||||
>
|
||||
用户管理
|
||||
</RouterLink>
|
||||
</template>
|
||||
<a
|
||||
href="#"
|
||||
class="px-3 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
|
||||
@@ -20,11 +20,23 @@ const router = createRouter({
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('../views/AdminView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
meta: { requiresAuth: true, adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/users',
|
||||
name: 'users',
|
||||
component: () => import('../views/UserManageView.vue'),
|
||||
meta: { requiresAuth: true, adminOnly: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -34,8 +46,10 @@ router.beforeEach((to, from, next) => {
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
next({ name: 'login' })
|
||||
} else if (to.meta.adminOnly && authStore.user?.role !== 'admin') {
|
||||
next({ name: 'profile' })
|
||||
} else if (to.name === 'login' && authStore.isLoggedIn) {
|
||||
next({ name: 'admin' })
|
||||
next({ name: 'profile' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchUser()
|
||||
})
|
||||
|
||||
const showPasswordModal = ref(false)
|
||||
const passwordForm = ref({ old_password: '', new_password: '', confirm_password: '' })
|
||||
const passwordError = ref('')
|
||||
const changingPassword = ref(false)
|
||||
|
||||
function openPasswordModal() {
|
||||
passwordForm.value = { old_password: '', new_password: '', confirm_password: '' }
|
||||
passwordError.value = ''
|
||||
showPasswordModal.value = true
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
passwordError.value = ''
|
||||
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
|
||||
passwordError.value = '请填写原密码和新密码'
|
||||
return
|
||||
}
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
passwordError.value = '两次输入的新密码不一致'
|
||||
return
|
||||
}
|
||||
changingPassword.value = true
|
||||
try {
|
||||
const res = await fetch('/api/me/password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_password: passwordForm.value.old_password,
|
||||
new_password: passwordForm.value.new_password,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '密码修改失败')
|
||||
}
|
||||
showPasswordModal.value = false
|
||||
} catch (e: any) {
|
||||
passwordError.value = e.message || '密码修改失败'
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto px-4 py-12">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">用户信息</h2>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">基本信息</h3>
|
||||
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<p><span class="font-medium text-gray-900 dark:text-white">用户名:</span>{{ authStore.user?.username }}</p>
|
||||
<p><span class="font-medium text-gray-900 dark:text-white">角色:</span>{{ authStore.user?.role === 'admin' ? '管理员' : '普通用户' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
@click="openPasswordModal"
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
|
||||
<div v-if="showPasswordModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showPasswordModal = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">修改密码</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">原密码</label>
|
||||
<input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密码</label>
|
||||
<input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">确认新密码</label>
|
||||
<input
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="passwordError" class="text-sm text-red-500">{{ passwordError }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
@click="showPasswordModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
|
||||
:disabled="changingPassword"
|
||||
@click="handleChangePassword"
|
||||
>
|
||||
{{ changingPassword ? '保存中...' : '确定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,372 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const users = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
status: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '加载失败')
|
||||
}
|
||||
const data = await res.json()
|
||||
users.value = data.users
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const createForm = ref({ username: '', password: '', role: 'user', status: 1 })
|
||||
const createError = ref('')
|
||||
const creating = ref(false)
|
||||
|
||||
async function handleCreate() {
|
||||
createError.value = ''
|
||||
if (!createForm.value.username || !createForm.value.password) {
|
||||
createError.value = '请填写用户名和密码'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: createForm.value.username,
|
||||
password: createForm.value.password,
|
||||
role: createForm.value.role,
|
||||
status: createForm.value.status,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '创建失败')
|
||||
}
|
||||
showCreateModal.value = false
|
||||
createForm.value = { username: '', password: '', role: 'user', status: 1 }
|
||||
await fetchUsers()
|
||||
} catch (e: any) {
|
||||
createError.value = e.message || '创建失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showEditModal = ref(false)
|
||||
const editingUser = ref<User | null>(null)
|
||||
const editForm = ref({ role: 'user', status: 1, password: '' })
|
||||
const editError = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
function openEditModal(user: User) {
|
||||
editingUser.value = user
|
||||
editForm.value = { role: user.role, status: user.status, password: '' }
|
||||
editError.value = ''
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
if (!editingUser.value) return
|
||||
editError.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const body: any = { role: editForm.value.role, status: editForm.value.status }
|
||||
if (editForm.value.password) {
|
||||
body.password = editForm.value.password
|
||||
}
|
||||
const res = await fetch(`/api/admin/users/${editingUser.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '保存失败')
|
||||
}
|
||||
showEditModal.value = false
|
||||
await fetchUsers()
|
||||
} catch (e: any) {
|
||||
editError.value = e.message || '保存失败'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingUser = ref<User | null>(null)
|
||||
const deleteError = ref('')
|
||||
const deleting = ref(false)
|
||||
|
||||
function confirmDelete(user: User) {
|
||||
deletingUser.value = user
|
||||
deleteError.value = ''
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingUser.value) return
|
||||
deleteError.value = ''
|
||||
deleting.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${deletingUser.value.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${authStore.token}` },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || '删除失败')
|
||||
}
|
||||
showDeleteConfirm.value = false
|
||||
await fetchUsers()
|
||||
} catch (e: any) {
|
||||
deleteError.value = e.message || '删除失败'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">用户管理</h2>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-red-500 mb-4">{{ error }}</p>
|
||||
|
||||
<div v-if="loading" class="text-gray-500 dark:text-gray-400 py-8 text-center">加载中...</div>
|
||||
|
||||
<div v-else class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">ID</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">用户名</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">角色</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">状态</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">创建时间</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ user.id }}</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white font-medium">{{ user.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.role === 'admin' ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'"
|
||||
>
|
||||
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="user.status === 1 ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ user.status === 1 ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{{ user.created_at }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded-md font-medium text-sky-700 bg-sky-50 hover:bg-sky-100 dark:text-sky-400 dark:bg-sky-900/20 dark:hover:bg-sky-900/40 transition-colors"
|
||||
@click="openEditModal(user)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded-md font-medium text-red-700 bg-red-50 hover:bg-red-100 dark:text-red-400 dark:bg-red-900/20 dark:hover:bg-red-900/40 transition-colors"
|
||||
@click="confirmDelete(user)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showCreateModal = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">新增用户</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">用户名</label>
|
||||
<input
|
||||
v-model="createForm.username"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">密码</label>
|
||||
<input
|
||||
v-model="createForm.password"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">角色</label>
|
||||
<select
|
||||
v-model="createForm.role"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">状态</label>
|
||||
<select
|
||||
v-model.number="createForm.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
<option :value="1">启用</option>
|
||||
<option :value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="createError" class="text-sm text-red-500">{{ createError }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
@click="showCreateModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
|
||||
:disabled="creating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{ creating ? '创建中...' : '确定' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showEditModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showEditModal = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">编辑用户 - {{ editingUser?.username }}</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">角色</label>
|
||||
<select
|
||||
v-model="editForm.role"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">状态</label>
|
||||
<select
|
||||
v-model.number="editForm.status"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||
>
|
||||
<option :value="1">启用</option>
|
||||
<option :value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密码(留空不修改)</label>
|
||||
<input
|
||||
v-model="editForm.password"
|
||||
type="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 placeholder-gray-400 dark:placeholder-gray-500"
|
||||
placeholder="留空则不修改密码"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="editError" class="text-sm text-red-500">{{ editError }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
@click="showEditModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 disabled:opacity-50 transition-colors"
|
||||
:disabled="saving"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showDeleteConfirm = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">确认删除</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
确定要删除用户 <span class="font-medium text-gray-900 dark:text-white">{{ deletingUser?.username }}</span> 吗?此操作不可撤销。
|
||||
</p>
|
||||
<p v-if="deleteError" class="text-sm text-red-500 mb-4">{{ deleteError }}</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-500 hover:bg-red-600 disabled:opacity-50 transition-colors"
|
||||
:disabled="deleting"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ deleting ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,6 +66,50 @@ func Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
|
||||
func ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入原密码和新密码"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 6 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "新密码长度不能少于6位"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "原密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&user).Update("password", string(hash)).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码修改失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
|
||||
}
|
||||
|
||||
func Me(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
username, _ := c.Get("username")
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"lmvpn/internal/db"
|
||||
"lmvpn/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type createUserRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Role string `json:"role"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Status *int `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Status int `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
func formatUser(u *model.User) userResponse {
|
||||
return userResponse{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: u.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
func ListUsers(c *gin.Context) {
|
||||
var users []model.User
|
||||
db.DB.Order("id asc").Find(&users)
|
||||
|
||||
result := make([]userResponse, len(users))
|
||||
for i, u := range users {
|
||||
result[i] = formatUser(&u)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": result})
|
||||
}
|
||||
|
||||
func CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var exist model.User
|
||||
if err := db.DB.Where("username = ?", req.Username).First(&exist).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "用户名已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
Username: req.Username,
|
||||
Password: string(hash),
|
||||
Role: "user",
|
||||
Status: 1,
|
||||
}
|
||||
if req.Role != "" {
|
||||
user.Role = req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, formatUser(&user))
|
||||
}
|
||||
|
||||
func UpdateUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID, _ := c.Get("user_id")
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if req.Role != "" {
|
||||
if user.ID == currentUserID.(uint) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能修改自己的角色"})
|
||||
return
|
||||
}
|
||||
if req.Role != "admin" && user.Role == "admin" {
|
||||
var adminCount int64
|
||||
db.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "系统至少需要保留一个管理员"})
|
||||
return
|
||||
}
|
||||
}
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
|
||||
if req.Password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
|
||||
return
|
||||
}
|
||||
updates["password"] = string(hash)
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "没有需要更新的字段"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.DB.Model(&user).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
db.DB.First(&user, id)
|
||||
c.JSON(http.StatusOK, formatUser(&user))
|
||||
}
|
||||
|
||||
func DeleteUser(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
currentUserID, _ := c.Get("user_id")
|
||||
|
||||
if uint(id) == currentUserID.(uint) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不能删除自己"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := db.DB.First(&user, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role == "admin" {
|
||||
var adminCount int64
|
||||
db.DB.Model(&model.User{}).Where("role = ?", "admin").Count(&adminCount)
|
||||
if adminCount <= 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "系统至少需要保留一个管理员"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DB.Delete(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||||
}
|
||||
@@ -51,6 +51,18 @@ func ParseToken(tokenStr string) (*Claims, error) {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
func AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
|
||||
@@ -20,6 +20,16 @@ func Setup(r *gin.Engine) {
|
||||
auth.Use(middleware.AuthMiddleware())
|
||||
{
|
||||
auth.GET("/me", handler.Me)
|
||||
auth.PUT("/me/password", handler.ChangePassword)
|
||||
}
|
||||
|
||||
admin := r.Group("/api/admin")
|
||||
admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware())
|
||||
{
|
||||
admin.GET("/users", handler.ListUsers)
|
||||
admin.POST("/users", handler.CreateUser)
|
||||
admin.PUT("/users/:id", handler.UpdateUser)
|
||||
admin.DELETE("/users/:id", handler.DeleteUser)
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.Dir("./dist"))
|
||||
|
||||
Reference in New Issue
Block a user