2025-12-04 10:04:21 +08:00

571 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>