405 lines
8.7 KiB
Vue
405 lines
8.7 KiB
Vue
<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>
|