986 lines
40 KiB
Vue
986 lines
40 KiB
Vue
<script setup>
|
||
import { ref, onMounted, computed } from "vue"
|
||
import AOS from "aos"
|
||
import "aos/dist/aos.css"
|
||
import { message, Upload } from "ant-design-vue"
|
||
|
||
onMounted(() => {
|
||
AOS.init({
|
||
duration: 800,
|
||
offset: 120,
|
||
easing: "ease-out-cubic",
|
||
once: false,
|
||
})
|
||
})
|
||
|
||
// 角色数据
|
||
const roleOptions = [
|
||
{ id: 1, name: "管理员" },
|
||
{ id: 2, name: "编辑" },
|
||
{ id: 3, name: "普通用户" },
|
||
{ id: 4, name: "访客" },
|
||
]
|
||
|
||
// 权限数据
|
||
const allPermissions = [
|
||
{ id: 1, name: "用户管理" },
|
||
{ id: 2, name: "内容管理" },
|
||
{ id: 3, name: "订单管理" },
|
||
{ id: 4, name: "系统设置" },
|
||
{ id: 5, name: "数据统计" },
|
||
{ id: 6, name: "财务管理" },
|
||
{ id: 7, name: "客户管理" },
|
||
{ id: 8, name: "营销管理" },
|
||
]
|
||
|
||
// 用户状态选项
|
||
const statusOptions = [
|
||
{ value: "启用", label: "启用" },
|
||
{ value: "禁用", label: "禁用" },
|
||
]
|
||
|
||
// 模拟用户数据
|
||
const dataSource = ref([
|
||
{
|
||
id: 1,
|
||
username: "admin",
|
||
avatar: "https://randomuser.me/api/portraits/men/32.jpg",
|
||
email: "admin@example.com",
|
||
phone: "13800138000",
|
||
role: roleOptions[0],
|
||
status: "启用",
|
||
createdAt: "2024-01-15T08:30:00Z",
|
||
lastLogin: "2024-03-28T10:15:30Z",
|
||
permissions: [
|
||
allPermissions[0],
|
||
allPermissions[1],
|
||
allPermissions[2],
|
||
allPermissions[3],
|
||
allPermissions[4],
|
||
],
|
||
},
|
||
{
|
||
id: 2,
|
||
username: "editor",
|
||
avatar: "https://randomuser.me/api/portraits/women/44.jpg",
|
||
email: "editor@example.com",
|
||
phone: "13900139000",
|
||
role: roleOptions[1],
|
||
status: "启用",
|
||
createdAt: "2024-02-10T14:20:00Z",
|
||
lastLogin: "2024-03-27T16:45:12Z",
|
||
permissions: [allPermissions[1], allPermissions[2]],
|
||
},
|
||
{
|
||
id: 3,
|
||
username: "user1",
|
||
avatar: "https://randomuser.me/api/portraits/men/22.jpg",
|
||
email: "user1@example.com",
|
||
phone: "13700137000",
|
||
role: roleOptions[2],
|
||
status: "启用",
|
||
createdAt: "2024-02-20T09:15:00Z",
|
||
lastLogin: "2024-03-25T11:30:45Z",
|
||
permissions: [allPermissions[2]],
|
||
},
|
||
{
|
||
id: 4,
|
||
username: "user2",
|
||
avatar: "https://randomuser.me/api/portraits/women/24.jpg",
|
||
email: "user2@example.com",
|
||
phone: "13600136000",
|
||
role: roleOptions[2],
|
||
status: "禁用",
|
||
createdAt: "2024-03-05T16:40:00Z",
|
||
lastLogin: "2024-03-20T09:10:22Z",
|
||
permissions: [allPermissions[2]],
|
||
},
|
||
{
|
||
id: 5,
|
||
username: "guest",
|
||
avatar: "https://randomuser.me/api/portraits/men/67.jpg",
|
||
email: "guest@example.com",
|
||
phone: "13500135000",
|
||
role: roleOptions[3],
|
||
status: "启用",
|
||
createdAt: "2024-03-10T11:25:00Z",
|
||
lastLogin: "2024-03-15T14:20:18Z",
|
||
permissions: [],
|
||
},
|
||
])
|
||
|
||
// 分页
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(10)
|
||
const totalItems = ref(dataSource.value.length)
|
||
|
||
const paginatedData = computed(() => {
|
||
const start = (currentPage.value - 1) * pageSize.value
|
||
const end = start + pageSize.value
|
||
return dataSource.value.slice(start, end)
|
||
})
|
||
|
||
const changePage = (page) => {
|
||
currentPage.value = page
|
||
}
|
||
|
||
// 搜索和筛选
|
||
const searchQuery = ref("")
|
||
const filterRole = ref("all")
|
||
const filterStatus = ref("all")
|
||
|
||
const filteredData = computed(() => {
|
||
return dataSource.value.filter(
|
||
(user) =>
|
||
(searchQuery.value === "" ||
|
||
user.username.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||
user.email.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||
user.phone.includes(searchQuery.value)) &&
|
||
(filterRole.value === "all" || user.role.id === parseInt(filterRole.value)) &&
|
||
(filterStatus.value === "all" || user.status === filterStatus.value)
|
||
)
|
||
})
|
||
|
||
const filteredAndPaginatedData = computed(() => {
|
||
const start = (currentPage.value - 1) * pageSize.value
|
||
const end = start + pageSize.value
|
||
return filteredData.value.slice(start, end)
|
||
})
|
||
|
||
// 模态框
|
||
const showModal = ref(false)
|
||
const currentItem = ref(null)
|
||
const formLoading = ref(false)
|
||
|
||
// 头像上传相关
|
||
const fileList = ref([])
|
||
const uploading = ref(false)
|
||
const previewVisible = ref(false)
|
||
const previewImage = ref("")
|
||
|
||
const handlePreview = (file) => {
|
||
previewImage.value = file.url || file.preview
|
||
previewVisible.value = true
|
||
}
|
||
|
||
const handleUploadChange = (info) => {
|
||
if (info.file.status === "uploading") {
|
||
uploading.value = true
|
||
return
|
||
}
|
||
if (info.file.status === "done") {
|
||
// 模拟上传成功后获取图片URL
|
||
currentItem.value.avatar = "https://randomuser.me/api/portraits/men/32.jpg"
|
||
uploading.value = false
|
||
message.success("头像上传成功")
|
||
}
|
||
if (info.file.status === "error") {
|
||
uploading.value = false
|
||
message.error("头像上传失败")
|
||
}
|
||
}
|
||
|
||
const beforeUpload = (file) => {
|
||
const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png"
|
||
if (!isJpgOrPng) {
|
||
message.error("只能上传JPG或PNG格式的图片!")
|
||
}
|
||
const isLt2M = file.size / 1024 / 1024 < 2
|
||
if (!isLt2M) {
|
||
message.error("图片大小不能超过2MB!")
|
||
}
|
||
return isJpgOrPng && isLt2M
|
||
}
|
||
|
||
const customRequest = ({ onSuccess }) => {
|
||
// 模拟上传
|
||
setTimeout(() => {
|
||
onSuccess("ok", { status: "done" })
|
||
}, 800)
|
||
}
|
||
|
||
const openModal = (item = null) => {
|
||
currentItem.value = item
|
||
? JSON.parse(JSON.stringify(item)) // 深拷贝避免直接修改原对象
|
||
: {
|
||
username: "",
|
||
avatar: "",
|
||
email: "",
|
||
phone: "",
|
||
role: roleOptions[2], // 默认为普通用户
|
||
status: "启用",
|
||
permissions: [],
|
||
}
|
||
|
||
// 重置文件列表
|
||
fileList.value = []
|
||
if (currentItem.value.avatar) {
|
||
fileList.value = [
|
||
{
|
||
uid: "-1",
|
||
name: "avatar.jpg",
|
||
status: "done",
|
||
url: currentItem.value.avatar,
|
||
},
|
||
]
|
||
}
|
||
|
||
showModal.value = true
|
||
}
|
||
|
||
const closeModal = () => {
|
||
showModal.value = false
|
||
}
|
||
|
||
// 确认删除模态框
|
||
const showDeleteConfirm = ref(false)
|
||
const itemToDelete = ref(null)
|
||
|
||
const openDeleteConfirm = (id) => {
|
||
itemToDelete.value = id
|
||
showDeleteConfirm.value = true
|
||
}
|
||
|
||
const closeDeleteConfirm = () => {
|
||
showDeleteConfirm.value = false
|
||
itemToDelete.value = null
|
||
}
|
||
|
||
const handleDelete = () => {
|
||
if (itemToDelete.value) {
|
||
dataSource.value = dataSource.value.filter((item) => item.id !== itemToDelete.value)
|
||
totalItems.value = dataSource.value.length
|
||
closeDeleteConfirm()
|
||
message.success("用户删除成功")
|
||
}
|
||
}
|
||
|
||
const formatDate = (dateString) => {
|
||
const date = new Date(dateString)
|
||
return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
formLoading.value = true
|
||
|
||
// 模拟API请求
|
||
setTimeout(() => {
|
||
if (currentItem.value.id) {
|
||
// 更新现有用户
|
||
const index = dataSource.value.findIndex((item) => item.id === currentItem.value.id)
|
||
if (index !== -1) {
|
||
dataSource.value[index] = { ...currentItem.value }
|
||
}
|
||
message.success("用户更新成功")
|
||
} else {
|
||
// 添加新用户
|
||
const newId = Math.max(...dataSource.value.map((item) => item.id), 0) + 1
|
||
dataSource.value.push({
|
||
...currentItem.value,
|
||
id: newId,
|
||
createdAt: new Date().toISOString(),
|
||
lastLogin: null,
|
||
})
|
||
totalItems.value = dataSource.value.length
|
||
message.success("用户创建成功")
|
||
}
|
||
|
||
formLoading.value = false
|
||
closeModal()
|
||
}, 800)
|
||
}
|
||
|
||
// 权限管理
|
||
const permissionInputValue = ref("")
|
||
const handleAddPermission = () => {
|
||
if (permissionInputValue.value) {
|
||
// 检查是否已存在该权限
|
||
const existingPermission = allPermissions.find(
|
||
(permission) => permission.name.toLowerCase() === permissionInputValue.value.toLowerCase()
|
||
)
|
||
|
||
// 如果存在且未添加到当前用户,则添加
|
||
if (
|
||
existingPermission &&
|
||
!currentItem.value.permissions.some((permission) => permission.id === existingPermission.id)
|
||
) {
|
||
currentItem.value.permissions.push(existingPermission)
|
||
}
|
||
// 如果不存在,创建新权限并添加
|
||
else if (!existingPermission) {
|
||
const newPermissionId = Math.max(...allPermissions.map((permission) => permission.id), 0) + 1
|
||
const newPermission = { id: newPermissionId, name: permissionInputValue.value }
|
||
allPermissions.push(newPermission)
|
||
currentItem.value.permissions.push(newPermission)
|
||
}
|
||
|
||
permissionInputValue.value = ""
|
||
}
|
||
}
|
||
|
||
const removePermission = (index) => {
|
||
currentItem.value.permissions.splice(index, 1)
|
||
}
|
||
|
||
// 重置密码模态框
|
||
const showResetPasswordModal = ref(false)
|
||
const resetPasswordUserId = ref(null)
|
||
const resetPasswordUsername = ref("")
|
||
const newPassword = ref("")
|
||
const confirmPassword = ref("")
|
||
const resetPasswordLoading = ref(false)
|
||
const passwordError = ref("")
|
||
|
||
const openResetPasswordModal = (user) => {
|
||
resetPasswordUserId.value = user.id
|
||
resetPasswordUsername.value = user.username
|
||
newPassword.value = ""
|
||
confirmPassword.value = ""
|
||
passwordError.value = ""
|
||
showResetPasswordModal.value = true
|
||
}
|
||
|
||
const closeResetPasswordModal = () => {
|
||
showResetPasswordModal.value = false
|
||
}
|
||
|
||
const handleResetPassword = () => {
|
||
// 验证密码
|
||
if (!newPassword.value) {
|
||
passwordError.value = "请输入新密码"
|
||
return
|
||
}
|
||
|
||
if (newPassword.value.length < 6) {
|
||
passwordError.value = "密码长度不能少于6位"
|
||
return
|
||
}
|
||
|
||
if (newPassword.value !== confirmPassword.value) {
|
||
passwordError.value = "两次输入的密码不一致"
|
||
return
|
||
}
|
||
|
||
resetPasswordLoading.value = true
|
||
|
||
// 模拟API请求
|
||
setTimeout(() => {
|
||
message.success(`用户 ${resetPasswordUsername.value} 的密码已重置`)
|
||
resetPasswordLoading.value = false
|
||
closeResetPasswordModal()
|
||
}, 800)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6" data-aos="fade-up">
|
||
<!-- 页面标题和新建按钮 -->
|
||
<div class="mb-6 flex justify-between items-center">
|
||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">用户管理</h2>
|
||
<button
|
||
class="px-5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center cursor-pointer"
|
||
@click="openModal()"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
新建用户
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 搜索和筛选 -->
|
||
<div class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div class="relative">
|
||
<input
|
||
v-model="searchQuery"
|
||
type="text"
|
||
class="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="搜索用户名、邮箱或电话"
|
||
/>
|
||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<select
|
||
v-model="filterRole"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
|
||
>
|
||
<option value="all">所有角色</option>
|
||
<option v-for="role in roleOptions" :key="role.id" :value="role.id">{{ role.name }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<select
|
||
v-model="filterStatus"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
|
||
>
|
||
<option value="all">所有状态</option>
|
||
<option v-for="status in statusOptions" :key="status.value" :value="status.value">{{ status.label }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表格 -->
|
||
<div class="rounded-2xl border border-gray-200 dark:border-gray-700">
|
||
<div class="overflow-x-auto" style="height: 60vh">
|
||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||
<!-- 表头 -->
|
||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||
<tr>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
用户信息
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
联系方式
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
角色
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
状态
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
最后登录
|
||
</th>
|
||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
操作
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
|
||
<!-- 表格内容 -->
|
||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||
<tr v-for="user in filteredAndPaginatedData" :key="user.id" class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<div class="flex items-center space-x-3">
|
||
<div class="flex-shrink-0 h-10 w-10 rounded-full overflow-hidden">
|
||
<img :src="user.avatar" :alt="user.username" class="h-full w-full object-cover" />
|
||
</div>
|
||
<div>
|
||
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ user.username }}</div>
|
||
<div class="text-xs text-gray-500 dark:text-gray-400">ID: {{ user.id }}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<div class="text-sm text-gray-900 dark:text-white">{{ user.email }}</div>
|
||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ user.phone }}</div>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||
{{ user.role.name }}
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span
|
||
:class="`px-2 py-1 text-xs font-medium rounded-full ${
|
||
user.status === '启用'
|
||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
|
||
}`"
|
||
>
|
||
{{ user.status }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">
|
||
{{ user.lastLogin ? formatDate(user.lastLogin) : '从未登录' }}
|
||
</td>
|
||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<div class="flex justify-end space-x-2">
|
||
<button
|
||
class="p-1.5 text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors cursor-pointer"
|
||
title="编辑"
|
||
@click="openModal(user)"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-1.5 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors cursor-pointer"
|
||
title="重置密码"
|
||
@click="openResetPasswordModal(user)"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="p-1.5 text-rose-600 hover:text-rose-800 dark:text-rose-400 dark:hover:text-rose-300 hover:bg-rose-50 dark:hover:bg-rose-900/20 rounded-lg transition-colors cursor-pointer"
|
||
title="删除"
|
||
@click="openDeleteConfirm(user.id)"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="filteredAndPaginatedData.length === 0">
|
||
<td colspan="6" class="px-6 py-10 text-center text-gray-500 dark:text-gray-400">
|
||
没有找到匹配的用户
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<div class="bg-white dark:bg-gray-800 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||
共 {{ filteredData.length }} 条记录
|
||
</div>
|
||
<div class="flex space-x-1">
|
||
<button
|
||
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||
:disabled="currentPage === 1"
|
||
:class="{ 'opacity-50 cursor-not-allowed': currentPage === 1 }"
|
||
@click="currentPage > 1 && changePage(currentPage - 1)"
|
||
>
|
||
上一页
|
||
</button>
|
||
<button
|
||
v-for="page in Math.ceil(filteredData.length / pageSize)"
|
||
:key="page"
|
||
class="px-3 py-1 border rounded-md text-sm font-medium cursor-pointer"
|
||
:class="page === currentPage
|
||
? 'bg-rose-600 text-white border-rose-600 dark:bg-rose-600 dark:border-rose-600'
|
||
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'"
|
||
@click="changePage(page)"
|
||
>
|
||
{{ page }}
|
||
</button>
|
||
<button
|
||
class="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||
:disabled="currentPage === Math.ceil(filteredData.length / pageSize)"
|
||
:class="{ 'opacity-50 cursor-not-allowed': currentPage === Math.ceil(filteredData.length / pageSize) }"
|
||
@click="currentPage < Math.ceil(filteredData.length / pageSize) && changePage(currentPage + 1)"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户表单模态框 -->
|
||
<div v-if="showModal" class="fixed inset-0 z-50 overflow-y-scroll" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
|
||
aria-hidden="true"
|
||
@click="closeModal"
|
||
></div>
|
||
|
||
<!-- 使模态框居中 -->
|
||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||
|
||
<!-- 模态框内容 -->
|
||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full relative z-50">
|
||
<div class="bg-white dark:bg-gray-800 px-6 pt-5 pb-4 sm:p-6">
|
||
<div class="flex justify-between items-center mb-5">
|
||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white" id="modal-title">
|
||
{{ currentItem?.id ? '编辑用户' : '新建用户' }}
|
||
</h3>
|
||
<button
|
||
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 cursor-pointer"
|
||
@click="closeModal"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||
<!-- 基本信息 -->
|
||
<div class="grid grid-cols-1 gap-6">
|
||
<!-- 用户名 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">用户名</label>
|
||
<input
|
||
v-model="currentItem.username"
|
||
type="text"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="请输入用户名"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<!-- 邮箱 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">邮箱</label>
|
||
<input
|
||
v-model="currentItem.email"
|
||
type="email"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="请输入邮箱"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<!-- 电话 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">电话</label>
|
||
<input
|
||
v-model="currentItem.phone"
|
||
type="text"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="请输入电话"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 角色 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">角色</label>
|
||
<select
|
||
v-model="currentItem.role"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
|
||
>
|
||
<option v-for="role in roleOptions" :key="role.id" :value="role">
|
||
{{ role.name }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 状态 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">状态</label>
|
||
<select
|
||
v-model="currentItem.status"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white cursor-pointer"
|
||
>
|
||
<option v-for="option in statusOptions" :key="option.value" :value="option.value">
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- 头像上传 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">头像</label>
|
||
<div class="border border-gray-300 dark:border-gray-600 rounded-xl p-4 dark:bg-gray-700">
|
||
<Upload
|
||
v-model:file-list="fileList"
|
||
list-type="picture-card"
|
||
:before-upload="beforeUpload"
|
||
:custom-request="customRequest"
|
||
@preview="handlePreview"
|
||
@change="handleUploadChange"
|
||
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: true }"
|
||
:maxCount="1"
|
||
>
|
||
<div v-if="fileList.length < 1">
|
||
<div class="ant-upload-text flex flex-col items-center justify-center text-gray-500 dark:text-gray-400">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
<span>点击上传头像</span>
|
||
</div>
|
||
</div>
|
||
</Upload>
|
||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||
支持JPG、PNG格式,大小不超过2MB
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 权限 -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">权限</label>
|
||
<div class="flex flex-wrap gap-2 mb-2">
|
||
<span
|
||
v-for="(permission, index) in currentItem.permissions"
|
||
:key="index"
|
||
class="px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 text-sm font-medium rounded-lg flex items-center"
|
||
>
|
||
{{ permission.name }}
|
||
<button
|
||
type="button"
|
||
class="ml-1.5 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-200 cursor-pointer"
|
||
@click="removePermission(index)"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<div class="flex">
|
||
<select
|
||
v-model="permissionInputValue"
|
||
class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-l-xl focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||
>
|
||
<option value="">选择权限</option>
|
||
<option v-for="permission in allPermissions" :key="permission.id" :value="permission.name">
|
||
{{ permission.name }}
|
||
</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-r-xl transition-colors cursor-pointer"
|
||
@click="handleAddPermission"
|
||
>
|
||
添加
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表单按钮 -->
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button
|
||
type="button"
|
||
class="px-5 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||
@click="closeModal"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="px-5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center cursor-pointer"
|
||
:disabled="formLoading"
|
||
>
|
||
<span v-if="formLoading" class="mr-2">
|
||
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
</span>
|
||
{{ currentItem?.id ? '保存修改' : '创建用户' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 删除确认模态框 -->
|
||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
|
||
aria-hidden="true"
|
||
@click="closeDeleteConfirm"
|
||
></div>
|
||
|
||
<!-- 使模态框居中 -->
|
||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||
|
||
<!-- 模态框内容 -->
|
||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full relative z-50">
|
||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||
<div class="sm:flex sm:items-start">
|
||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
</div>
|
||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||
确认删除
|
||
</h3>
|
||
<div class="mt-2">
|
||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||
确定要删除这个用户吗?此操作无法撤销。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||
<button
|
||
type="button"
|
||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm cursor-pointer"
|
||
@click="handleDelete"
|
||
>
|
||
确认
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm cursor-pointer"
|
||
@click="closeDeleteConfirm"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 重置密码模态框 -->
|
||
<div v-if="showResetPasswordModal" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
|
||
aria-hidden="true"
|
||
@click="closeResetPasswordModal"
|
||
></div>
|
||
|
||
<!-- 使模态框居中 -->
|
||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||
|
||
<!-- 模态框内容 -->
|
||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full relative z-50">
|
||
<div class="bg-white dark:bg-gray-800 px-6 pt-5 pb-4 sm:p-6">
|
||
<div class="flex justify-between items-center mb-5">
|
||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white" id="modal-title">
|
||
重置密码 - {{ resetPasswordUsername }}
|
||
</h3>
|
||
<button
|
||
class="text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 cursor-pointer"
|
||
@click="closeResetPasswordModal"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<form @submit.prevent="handleResetPassword" class="space-y-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">新密码</label>
|
||
<input
|
||
v-model="newPassword"
|
||
type="password"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="请输入新密码"
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">确认密码</label>
|
||
<input
|
||
v-model="confirmPassword"
|
||
type="password"
|
||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-rose-500 focus:border-rose-500 dark:bg-gray-700 dark:text-white"
|
||
placeholder="请再次输入新密码"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="passwordError" class="text-sm text-red-600 dark:text-red-400">
|
||
{{ passwordError }}
|
||
</div>
|
||
|
||
<div class="flex justify-end space-x-3 pt-4">
|
||
<button
|
||
type="button"
|
||
class="px-5 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer"
|
||
@click="closeResetPasswordModal"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="px-5 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex items-center cursor-pointer"
|
||
:disabled="resetPasswordLoading"
|
||
>
|
||
<span v-if="resetPasswordLoading" class="mr-2">
|
||
<svg class="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
</span>
|
||
确认重置
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图片预览模态框 -->
|
||
<div v-if="previewVisible" class="fixed inset-0 z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<!-- 背景遮罩 -->
|
||
<div
|
||
class="fixed inset-0 transition-opacity backdrop-blur-xl bg-gray-500/80 dark:bg-gray-900/80"
|
||
aria-hidden="true"
|
||
@click="previewVisible = false"
|
||
></div>
|
||
|
||
<!-- 使模态框居中 -->
|
||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||
|
||
<!-- 模态框内容 -->
|
||
<div class="inline-block align-bottom bg-transparent rounded-lg text-left overflow-hidden transform transition-all sm:my-8 sm:align-middle sm:max-w-3xl sm:w-full relative z-50">
|
||
<div class="relative">
|
||
<button
|
||
class="absolute top-2 right-2 p-2 rounded-full bg-gray-800/50 text-white hover:bg-gray-700/50 transition-colors cursor-pointer"
|
||
@click="previewVisible = false"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
<img :src="previewImage" class="max-h-[80vh] max-w-full rounded-lg shadow-xl" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* 自定义样式 */
|
||
|
||
/* 毛玻璃效果增强 */
|
||
.backdrop-blur-xl {
|
||
backdrop-filter: blur(16px);
|
||
}
|
||
|
||
.backdrop-blur-md {
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.backdrop-blur-sm {
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
/* 上传组件暗黑模式适配 */
|
||
:deep(.ant-upload-list-picture-card .ant-upload-list-item) {
|
||
border-color: #4b5563;
|
||
}
|
||
|
||
:deep(.ant-upload.ant-upload-select-picture-card) {
|
||
background-color: transparent;
|
||
border-color: #4b5563;
|
||
}
|
||
|
||
/* 滚动条美化 */
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background-color: rgba(156, 163, 175, 0.5);
|
||
border-radius: 20px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background-color: rgba(156, 163, 175, 0.7);
|
||
}
|
||
|
||
/* 暗黑模式滚动条 */
|
||
.dark ::-webkit-scrollbar-thumb {
|
||
background-color: rgba(75, 85, 99, 0.5);
|
||
}
|
||
|
||
.dark ::-webkit-scrollbar-thumb:hover {
|
||
background-color: rgba(75, 85, 99, 0.7);
|
||
}
|
||
</style> |