571 lines
13 KiB
Vue
571 lines
13 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="admin-page">
|
|||
|
|
<section class="card">
|
|||
|
|
<div class="filter-row">
|
|||
|
|
<input v-model="filters.search" class="input" type="text" placeholder="搜索用户名 / 邮箱" />
|
|||
|
|
<select v-model="filters.roleId" class="input">
|
|||
|
|
<option value="">全部角色</option>
|
|||
|
|
<option v-for="role in roles" :key="role.id" :value="role.id">
|
|||
|
|
{{ role.name }}
|
|||
|
|
</option>
|
|||
|
|
</select>
|
|||
|
|
<button class="btn" type="button" @click="loadUsers">查询</button>
|
|||
|
|
<button class="btn ghost" type="button" @click="loadUsers" :disabled="loading.users">
|
|||
|
|
{{ loading.users ? '刷新中...' : '刷新列表' }}
|
|||
|
|
</button>
|
|||
|
|
<button class="btn primary" type="button" @click="openCreateDialog">新建用户</button>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section class="card table-card">
|
|||
|
|
<div v-if="loading.users" class="empty">用户列表加载中...</div>
|
|||
|
|
<div v-else-if="users.length === 0" class="empty">暂无用户</div>
|
|||
|
|
<div v-else class="table-wrapper">
|
|||
|
|
<table class="table">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th>用户</th>
|
|||
|
|
<th>邮箱</th>
|
|||
|
|
<th>角色</th>
|
|||
|
|
<th style="width: 180px">操作</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="user in users" :key="user.id">
|
|||
|
|
<td>
|
|||
|
|
<div class="user-name">{{ user.username }}</div>
|
|||
|
|
<div class="user-meta">创建于:{{ formatDate(user.created_at) }}</div>
|
|||
|
|
</td>
|
|||
|
|
<td>{{ user.email }}</td>
|
|||
|
|
<td>
|
|||
|
|
<div class="role-tags">
|
|||
|
|
<span v-for="role in user.roles" :key="role.id">{{ role.name }}</span>
|
|||
|
|
<span v-if="!user.roles.length">--</span>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td class="actions">
|
|||
|
|
<button class="btn small primary" type="button" @click="openEditDialog(user)">
|
|||
|
|
编辑
|
|||
|
|
</button>
|
|||
|
|
<button class="btn small danger" type="button" @click="deleteUser(user)">
|
|||
|
|
删除
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<!-- 创建用户 -->
|
|||
|
|
<div v-if="showCreate" class="modal-mask">
|
|||
|
|
<div class="modal">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h3>新建用户</h3>
|
|||
|
|
<button class="modal-close" type="button" @click="showCreate = false">×</button>
|
|||
|
|
</div>
|
|||
|
|
<form class="form" @submit.prevent="submitCreate">
|
|||
|
|
<label>用户名</label>
|
|||
|
|
<input v-model="createForm.username" class="input" type="text" required />
|
|||
|
|
|
|||
|
|
<label>邮箱</label>
|
|||
|
|
<input v-model="createForm.email" class="input" type="email" required />
|
|||
|
|
|
|||
|
|
<label>初始密码</label>
|
|||
|
|
<input v-model="createForm.password" class="input" type="password" required />
|
|||
|
|
|
|||
|
|
<label>简介</label>
|
|||
|
|
<textarea v-model="createForm.bio" class="input" rows="2" />
|
|||
|
|
|
|||
|
|
<label>分配角色</label>
|
|||
|
|
<div class="role-checkboxes">
|
|||
|
|
<label v-for="role in roles" :key="role.id">
|
|||
|
|
<input v-model="createForm.roleIds" type="checkbox" :value="role.id" />
|
|||
|
|
{{ role.name }}
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button class="btn" type="button" @click="showCreate = false" :disabled="loading.create">
|
|||
|
|
取消
|
|||
|
|
</button>
|
|||
|
|
<button class="btn primary" type="submit" :disabled="loading.create">
|
|||
|
|
{{ loading.create ? '创建中...' : '创建用户' }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 编辑用户 -->
|
|||
|
|
<div v-if="showEdit && editingUser" class="modal-mask">
|
|||
|
|
<div class="modal">
|
|||
|
|
<div class="modal-header">
|
|||
|
|
<h3>编辑用户</h3>
|
|||
|
|
<button class="modal-close" type="button" @click="closeEdit">×</button>
|
|||
|
|
</div>
|
|||
|
|
<form class="form" @submit.prevent="submitEdit">
|
|||
|
|
<label>用户名</label>
|
|||
|
|
<input v-model="editingUser.username" class="input" type="text" required />
|
|||
|
|
|
|||
|
|
<label>邮箱</label>
|
|||
|
|
<input v-model="editingUser.email" class="input" type="email" required />
|
|||
|
|
|
|||
|
|
<label>简介</label>
|
|||
|
|
<textarea v-model="editingUser.bio" class="input" rows="2" />
|
|||
|
|
|
|||
|
|
<label>角色</label>
|
|||
|
|
<div class="role-checkboxes">
|
|||
|
|
<label v-for="role in roles" :key="role.id">
|
|||
|
|
<input v-model="editingUser.roleIds" type="checkbox" :value="role.id" />
|
|||
|
|
{{ role.name }}
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="modal-footer">
|
|||
|
|
<button class="btn" type="button" @click="closeEdit" :disabled="loading.save">
|
|||
|
|
取消
|
|||
|
|
</button>
|
|||
|
|
<button class="btn primary" type="submit" :disabled="loading.save">
|
|||
|
|
保存
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { onMounted, reactive, ref, watch, computed, type Ref } from 'vue'
|
|||
|
|
import { navigateTo } from '#app'
|
|||
|
|
import { useAuth } from '@/composables/useAuth'
|
|||
|
|
import { useAuthToken, useApi } from '@/composables/useApi'
|
|||
|
|
|
|||
|
|
definePageMeta({
|
|||
|
|
layout: 'admin',
|
|||
|
|
adminTitle: '用户管理',
|
|||
|
|
adminSub: '按关键字和角色过滤,支持新增、编辑与删除用户。',
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
interface AdminRole {
|
|||
|
|
id: number
|
|||
|
|
name: string
|
|||
|
|
description?: string | null
|
|||
|
|
permissions: string[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AdminUser {
|
|||
|
|
id: number
|
|||
|
|
username: string
|
|||
|
|
email: string
|
|||
|
|
bio?: string | null
|
|||
|
|
roles: AdminRole[]
|
|||
|
|
created_at?: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AdminUsersResponse {
|
|||
|
|
users?: AdminUser[]
|
|||
|
|
total?: number
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AdminRolesResponse {
|
|||
|
|
roles?: AdminRole[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { token } = useAuthToken()
|
|||
|
|
const { user: authUser, fetchMe } = useAuth() as {
|
|||
|
|
user: Ref<{ roles?: string[] } | null>
|
|||
|
|
fetchMe: () => Promise<unknown>
|
|||
|
|
}
|
|||
|
|
const api = useApi()
|
|||
|
|
|
|||
|
|
const hasAccess = ref(false)
|
|||
|
|
const users = ref<AdminUser[]>([])
|
|||
|
|
const roles = ref<AdminRole[]>([])
|
|||
|
|
const loading = reactive({
|
|||
|
|
users: false,
|
|||
|
|
create: false,
|
|||
|
|
save: false,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const filters = reactive({
|
|||
|
|
search: '',
|
|||
|
|
roleId: '' as string | number,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const createForm = reactive({
|
|||
|
|
username: '',
|
|||
|
|
email: '',
|
|||
|
|
password: '',
|
|||
|
|
bio: '',
|
|||
|
|
roleIds: [] as number[],
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const editingUser = ref<{
|
|||
|
|
id: number
|
|||
|
|
username: string
|
|||
|
|
email: string
|
|||
|
|
bio: string
|
|||
|
|
roleIds: number[]
|
|||
|
|
} | null>(null)
|
|||
|
|
|
|||
|
|
const showCreate = ref(false)
|
|||
|
|
const showEdit = ref(false)
|
|||
|
|
|
|||
|
|
const isAdmin = computed(() => {
|
|||
|
|
const rs = authUser.value?.roles
|
|||
|
|
return Array.isArray(rs) && rs.includes('admin')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
async function ensureAccess(redirect = true): Promise<boolean> {
|
|||
|
|
if (!token.value) {
|
|||
|
|
hasAccess.value = false
|
|||
|
|
if (redirect) await navigateTo('/login')
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if (!authUser.value) {
|
|||
|
|
try {
|
|||
|
|
await fetchMe()
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[Admin] fetch user failed', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const ok = isAdmin.value
|
|||
|
|
hasAccess.value = ok
|
|||
|
|
if (!ok && redirect) await navigateTo('/')
|
|||
|
|
return ok
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDate(value?: string | null): string {
|
|||
|
|
if (!value) return '--'
|
|||
|
|
try {
|
|||
|
|
return new Date(value).toLocaleDateString()
|
|||
|
|
} catch {
|
|||
|
|
return '--'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadRoles(): Promise<void> {
|
|||
|
|
if (!hasAccess.value) return
|
|||
|
|
try {
|
|||
|
|
const res = (await api.get('/admin/roles')) as AdminRolesResponse
|
|||
|
|
roles.value = Array.isArray(res.roles) ? res.roles : []
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[Admin] load roles failed', err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadUsers(): Promise<void> {
|
|||
|
|
if (!hasAccess.value) return
|
|||
|
|
loading.users = true
|
|||
|
|
try {
|
|||
|
|
const query: Record<string, any> = { limit: 50, offset: 0 }
|
|||
|
|
const search = filters.search.trim()
|
|||
|
|
if (search) query.search = search
|
|||
|
|
const roleId = Number(filters.roleId)
|
|||
|
|
if (!Number.isNaN(roleId) && roleId > 0) query.role_id = roleId
|
|||
|
|
const res = (await api.get('/admin/users', query)) as AdminUsersResponse
|
|||
|
|
users.value = Array.isArray(res.users) ? res.users : []
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('[Admin] load users failed', err)
|
|||
|
|
alert('加载用户列表失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.users = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetCreateForm(): void {
|
|||
|
|
createForm.username = ''
|
|||
|
|
createForm.email = ''
|
|||
|
|
createForm.password = ''
|
|||
|
|
createForm.bio = ''
|
|||
|
|
createForm.roleIds = []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openCreateDialog(): void {
|
|||
|
|
resetCreateForm()
|
|||
|
|
showCreate.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openEditDialog(user: AdminUser): void {
|
|||
|
|
editingUser.value = {
|
|||
|
|
id: user.id,
|
|||
|
|
username: user.username,
|
|||
|
|
email: user.email,
|
|||
|
|
bio: user.bio || '',
|
|||
|
|
roleIds: (user.roles || []).map((r) => r.id),
|
|||
|
|
}
|
|||
|
|
showEdit.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeEdit(): void {
|
|||
|
|
editingUser.value = null
|
|||
|
|
showEdit.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitCreate(): Promise<void> {
|
|||
|
|
if (!hasAccess.value) return
|
|||
|
|
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
|
|||
|
|
alert('请完整填写用户名、邮箱和密码')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
loading.create = true
|
|||
|
|
try {
|
|||
|
|
await api.post('/admin/users', {
|
|||
|
|
user: {
|
|||
|
|
username: createForm.username.trim(),
|
|||
|
|
email: createForm.email.trim(),
|
|||
|
|
password: createForm.password,
|
|||
|
|
bio: createForm.bio,
|
|||
|
|
role_ids: createForm.roleIds,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
resetCreateForm()
|
|||
|
|
showCreate.value = false
|
|||
|
|
await loadUsers()
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Admin] create user failed', err)
|
|||
|
|
alert(err?.statusMessage || '创建用户失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.create = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function submitEdit(): Promise<void> {
|
|||
|
|
if (!hasAccess.value || !editingUser.value) return
|
|||
|
|
loading.save = true
|
|||
|
|
try {
|
|||
|
|
await api.put(`/admin/users/${editingUser.value.id}`, {
|
|||
|
|
user: {
|
|||
|
|
username: editingUser.value.username,
|
|||
|
|
email: editingUser.value.email,
|
|||
|
|
bio: editingUser.value.bio,
|
|||
|
|
role_ids: editingUser.value.roleIds,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
closeEdit()
|
|||
|
|
await loadUsers()
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Admin] save user failed', err)
|
|||
|
|
alert(err?.statusMessage || '保存用户失败')
|
|||
|
|
} finally {
|
|||
|
|
loading.save = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function deleteUser(user: AdminUser): Promise<void> {
|
|||
|
|
if (!hasAccess.value || !user?.id) return
|
|||
|
|
if (!confirm(`确定删除用户 ${user.username} 吗?`)) return
|
|||
|
|
try {
|
|||
|
|
await api.del(`/admin/users/${user.id}`)
|
|||
|
|
await loadUsers()
|
|||
|
|
} catch (err: any) {
|
|||
|
|
console.error('[Admin] delete user failed', err)
|
|||
|
|
alert(err?.statusMessage || '删除用户失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
if (await ensureAccess(true)) {
|
|||
|
|
await Promise.all([loadRoles(), loadUsers()])
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => authUser.value?.roles,
|
|||
|
|
async () => {
|
|||
|
|
if (await ensureAccess(false)) {
|
|||
|
|
await Promise.all([loadRoles(), loadUsers()])
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.admin-page {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card {
|
|||
|
|
background: #ffffff;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
padding: 14px 16px;
|
|||
|
|
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.filter-row {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 10px;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input {
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
min-width: 180px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn {
|
|||
|
|
padding: 8px 14px;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
border: 1px solid #d1d5db;
|
|||
|
|
background: #fff;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.18s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.primary {
|
|||
|
|
background: #111827;
|
|||
|
|
color: #fff;
|
|||
|
|
border-color: #111827;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.ghost {
|
|||
|
|
border-color: #e5e7eb;
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.danger {
|
|||
|
|
color: #b91c1c;
|
|||
|
|
border-color: #fecdd3;
|
|||
|
|
background: #fff5f5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.btn.small {
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table-card {
|
|||
|
|
padding: 10px 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table-wrapper {
|
|||
|
|
overflow-x: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table {
|
|||
|
|
width: 100%;
|
|||
|
|
border-collapse: collapse;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table th,
|
|||
|
|
.table td {
|
|||
|
|
padding: 9px 10px;
|
|||
|
|
border-bottom: 1px solid #f3f4f6;
|
|||
|
|
text-align: left;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.table th {
|
|||
|
|
color: #6b7280;
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.user-name {
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: #0f172a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.user-meta {
|
|||
|
|
margin-top: 2px;
|
|||
|
|
color: #9ca3af;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.role-tags {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.role-tags span {
|
|||
|
|
padding: 3px 8px;
|
|||
|
|
background: #eef2ff;
|
|||
|
|
color: #4338ca;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.actions {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty {
|
|||
|
|
padding: 18px 6px;
|
|||
|
|
text-align: center;
|
|||
|
|
color: #9ca3af;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-mask {
|
|||
|
|
position: fixed;
|
|||
|
|
inset: 0;
|
|||
|
|
background: rgba(15, 23, 42, 0.55);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
z-index: 30;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal {
|
|||
|
|
width: min(520px, 94vw);
|
|||
|
|
background: #fff;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 16px;
|
|||
|
|
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.45);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-header {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
margin-bottom: 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-close {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
font-size: 20px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: #6b7280;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.form label {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: #6b7280;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.role-checkboxes {
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px 12px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.modal-footer {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
gap: 8px;
|
|||
|
|
margin-top: 10px;
|
|||
|
|
}
|
|||
|
|
</style>
|