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

405 lines
8.7 KiB
Vue
Raw Permalink 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 table-card">
<div class="actions-row">
<button class="btn ghost" type="button" @click="loadRoles" :disabled="loading">
{{ loading ? '刷新中...' : '刷新列表' }}
</button>
<button class="btn primary" type="button" @click="openCreate">新建角色</button>
</div>
<div v-if="loading" class="empty">角色列表加载中...</div>
<div v-else-if="roles.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="role in roles" :key="role.id">
<td>{{ role.name }}</td>
<td>{{ role.description || '未填写描述' }}</td>
<td>{{ role.permissions.join(', ') || '未设置' }}</td>
<td class="actions">
<button class="btn small primary" type="button" @click="openEdit(role)">
编辑
</button>
<button class="btn small danger" type="button" @click="deleteRole(role)">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 角色弹窗 -->
<div v-if="showDialog" class="modal-mask">
<div class="modal">
<div class="modal-header">
<h3>{{ editingRole.id ? '编辑角色' : '新建角色' }}</h3>
<button class="modal-close" type="button" @click="closeDialog">×</button>
</div>
<form class="form" @submit.prevent="submitRole">
<label>角色名</label>
<input v-model="editingRole.name" class="input" type="text" required />
<label>描述</label>
<textarea v-model="editingRole.description" class="input" rows="2" />
<label>权限(用逗号隔开)</label>
<input
v-model="editingRole.permissionsInput"
class="input"
type="text"
placeholder="如articles:write, users:read"
/>
<div class="modal-footer">
<button class="btn" type="button" @click="closeDialog" :disabled="saving">
取消
</button>
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? '保存中...' : editingRole.id ? '保存修改' : '创建角色' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch, 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 AdminRolesResponse {
roles?: AdminRole[]
}
const { token } = useAuthToken()
const { user: authUser, fetchMe } = useAuth() as {
user: Ref<{ roles?: string[] } | null>
fetchMe: () => Promise<unknown>
}
const api = useApi()
const roles = ref<AdminRole[]>([])
const loading = ref(false)
const saving = ref(false)
const showDialog = ref(false)
const hasAccess = ref(false)
const editingRole = reactive({
id: null as number | null,
name: '',
description: '',
permissionsInput: '',
})
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 resetForm(): void {
editingRole.id = null
editingRole.name = ''
editingRole.description = ''
editingRole.permissionsInput = ''
}
function openCreate(): void {
resetForm()
showDialog.value = true
}
function openEdit(role: AdminRole): void {
editingRole.id = role.id
editingRole.name = role.name
editingRole.description = role.description || ''
editingRole.permissionsInput = (role.permissions || []).join(', ')
showDialog.value = true
}
function closeDialog(): void {
resetForm()
showDialog.value = false
}
async function loadRoles(): Promise<void> {
if (!hasAccess.value) return
loading.value = true
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)
alert('加载角色列表失败')
} finally {
loading.value = false
}
}
async function submitRole(): Promise<void> {
if (!hasAccess.value) return
if (!editingRole.name.trim()) {
alert('请输入角色名称')
return
}
saving.value = true
const permissions = editingRole.permissionsInput
.split(',')
.map((item) => item.trim())
.filter(Boolean)
const payload = {
role: {
name: editingRole.name.trim(),
description: editingRole.description,
permissions,
},
}
try {
if (editingRole.id) {
await api.put(`/admin/roles/${editingRole.id}`, payload)
} else {
await api.post('/admin/roles', payload)
}
closeDialog()
await loadRoles()
} catch (err: any) {
console.error('[Admin] save role failed', err)
alert(err?.statusMessage || '保存角色失败')
} finally {
saving.value = false
}
}
async function deleteRole(role: AdminRole): Promise<void> {
if (!hasAccess.value) return
if (role.name === 'admin') {
alert('admin 角色不允许删除')
return
}
if (!confirm(`确定删除角色 ${role.name} 吗?`)) return
try {
await api.del(`/admin/roles/${role.id}`)
await loadRoles()
} catch (err: any) {
console.error('[Admin] delete role failed', err)
alert(err?.statusMessage || '删除角色失败')
}
}
onMounted(async () => {
if (await ensureAccess(true)) {
await loadRoles()
}
})
watch(
() => authUser.value?.roles,
async () => {
if (await ensureAccess(false)) {
await loadRoles()
}
},
)
</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);
}
.actions-row {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.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: 12px 14px;
}
.table-wrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th,
.table td {
padding: 10px 10px;
border-bottom: 1px solid #f3f4f6;
text-align: left;
}
.table th {
color: #6b7280;
font-size: 13px;
font-weight: 500;
}
.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;
}
.input {
padding: 8px 10px;
border-radius: 10px;
border: 1px solid #d1d5db;
font-size: 14px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>