feat: add AdminPage and admin/logout buttons to BookListPage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,175 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineEmits<{ back: [] }>()
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { authedFetch, useAuth } from '../composables/useAuth'
|
||||||
|
import type { UserSummary } from '../composables/useAuth'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ back: [] }>()
|
||||||
|
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const users = ref<UserSummary[]>([])
|
||||||
|
const newUsername = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const newRole = ref<'user' | 'admin'>('user')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadUsers(): Promise<void> {
|
||||||
|
users.value = await authedFetch<UserSummary[]>('/api/admin/users')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(): Promise<void> {
|
||||||
|
if (!newUsername.value.trim() || !newPassword.value) return
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authedFetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: newUsername.value.trim(),
|
||||||
|
password: newPassword.value,
|
||||||
|
role: newRole.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
newUsername.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
newRole.value = 'user'
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '创建失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(id: string): Promise<void> {
|
||||||
|
if (!confirm('确定要删除该用户吗?')) return
|
||||||
|
try {
|
||||||
|
await authedFetch(`/api/admin/users/${id}`, { method: 'DELETE' })
|
||||||
|
await loadUsers()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '删除失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadUsers)
|
||||||
</script>
|
</script>
|
||||||
<template><div>Admin placeholder</div></template>
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-page">
|
||||||
|
<header>
|
||||||
|
<button @click="emit('back')">← 返回</button>
|
||||||
|
<h1>用户管理</h1>
|
||||||
|
<button @click="handleLogout">退出登录</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="create-user">
|
||||||
|
<h2>新建用户</h2>
|
||||||
|
<form @submit.prevent="createUser">
|
||||||
|
<input v-model="newUsername" placeholder="用户名" :disabled="loading" />
|
||||||
|
<input v-model="newPassword" type="password" placeholder="密码" :disabled="loading" />
|
||||||
|
<select v-model="newRole" :disabled="loading">
|
||||||
|
<option value="user">普通用户</option>
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" :disabled="loading || !newUsername || !newPassword">创建</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="user-list">
|
||||||
|
<h2>所有用户</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>角色</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in users" :key="u.id">
|
||||||
|
<td>{{ u.username }}</td>
|
||||||
|
<td>{{ u.role === 'admin' ? '管理员' : '普通用户' }}</td>
|
||||||
|
<td>{{ new Date(u.createdAt).toLocaleDateString('zh-CN') }}</td>
|
||||||
|
<td>
|
||||||
|
<button @click="removeUser(u.id)">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-page {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-user input,
|
||||||
|
.create-user select {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #c0392b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import * as booksApi from '../services/booksApi'
|
|||||||
import BookListPage from './BookListPage.vue'
|
import BookListPage from './BookListPage.vue'
|
||||||
|
|
||||||
vi.mock('../services/booksApi')
|
vi.mock('../services/booksApi')
|
||||||
|
vi.mock('../composables/useAuth', () => ({
|
||||||
|
useAuth: () => ({ user: { value: null }, logout: vi.fn() }),
|
||||||
|
authedFetch: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('BookListPage', () => {
|
describe('BookListPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import * as booksApi from '../services/booksApi'
|
import * as booksApi from '../services/booksApi'
|
||||||
import type { BookSummary } from '../services/booksApi'
|
import type { BookSummary } from '../services/booksApi'
|
||||||
|
import { useAuth } from '../composables/useAuth'
|
||||||
|
|
||||||
type LoadStatus = 'loading' | 'loaded' | 'error'
|
type LoadStatus = 'loading' | 'loaded' | 'error'
|
||||||
|
|
||||||
const emit = defineEmits<{ open: [id: string] }>()
|
const emit = defineEmits<{ open: [id: string]; admin: [] }>()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
const books = ref<BookSummary[]>([])
|
const books = ref<BookSummary[]>([])
|
||||||
const loadStatus = ref<LoadStatus>('loading')
|
const loadStatus = ref<LoadStatus>('loading')
|
||||||
@@ -102,7 +104,13 @@ async function removeBook(book: BookSummary): Promise<void> {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="book-list-page">
|
<div class="book-list-page">
|
||||||
<h1>教学设计</h1>
|
<div class="page-header">
|
||||||
|
<h1>教学设计</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button v-if="user?.role === 'admin'" type="button" @click="emit('admin')">用户管理</button>
|
||||||
|
<button type="button" @click="logout">退出登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="book-list-create" @submit.prevent="createBook">
|
<form class="book-list-create" @submit.prevent="createBook">
|
||||||
<input v-model="newBookName" type="text" placeholder="新整本名称" aria-label="新整本名称" />
|
<input v-model="newBookName" type="text" placeholder="新整本名称" aria-label="新整本名称" />
|
||||||
@@ -143,3 +151,21 @@ async function removeBook(book: BookSummary): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user