6 changed files with 975 additions and 4 deletions
@ -1,5 +1,806 @@ |
|||
<script setup lang="ts"> |
|||
interface UserRow { |
|||
id: number |
|||
username: string |
|||
email: string | null |
|||
nickname: string | null |
|||
avatar: string | null |
|||
role: string |
|||
status: string |
|||
createdAt: Date | null |
|||
} |
|||
|
|||
const currentPage = ref(1) |
|||
const pageSize = ref(10) |
|||
const searchQuery = ref("") |
|||
|
|||
const { data, refresh, pending } = await useHttpFetch("/api/users", { |
|||
query: computed(() => ({ |
|||
page: currentPage.value, |
|||
pageSize: pageSize.value, |
|||
search: searchQuery.value || undefined, |
|||
})), |
|||
watch: [currentPage, pageSize, searchQuery], |
|||
}) |
|||
|
|||
const userList = computed<UserRow[]>(() => (data.value as any)?.list ?? []) |
|||
const totalUsers = computed(() => (data.value as any)?.total ?? 0) |
|||
const totalPages = computed(() => (data.value as any)?.totalPages ?? 1) |
|||
|
|||
// Create modal state |
|||
const showCreateModal = ref(false) |
|||
const createForm = ref({ |
|||
username: "", |
|||
password: "", |
|||
email: "", |
|||
role: "user" as "user" | "admin", |
|||
}) |
|||
const createLoading = ref(false) |
|||
const createError = ref("") |
|||
|
|||
// Delete confirmation |
|||
const deletingId = ref<number | null>(null) |
|||
|
|||
function roleBadge(role: string) { |
|||
const map: Record<string, { label: string; class: string }> = { |
|||
admin: { label: "管理员", class: "role-admin" }, |
|||
user: { label: "用户", class: "role-user" }, |
|||
} |
|||
return map[role] ?? { label: role, class: "role-user" } |
|||
} |
|||
|
|||
function statusBadge(status: string) { |
|||
const map: Record<string, { label: string; class: string }> = { |
|||
active: { label: "正常", class: "status-active" }, |
|||
disabled: { label: "禁用", class: "status-disabled" }, |
|||
} |
|||
return map[status] ?? { label: status, class: "status-disabled" } |
|||
} |
|||
|
|||
function formatDate(date: Date | null) { |
|||
if (!date) return "-" |
|||
return new Intl.DateTimeFormat("zh-CN", { |
|||
year: "numeric", |
|||
month: "2-digit", |
|||
day: "2-digit", |
|||
hour: "2-digit", |
|||
minute: "2-digit", |
|||
}).format(new Date(date)) |
|||
} |
|||
|
|||
async function handleDelete(id: number) { |
|||
if (!confirm("确定删除此用户?此操作不可撤销。")) return |
|||
deletingId.value = id |
|||
try { |
|||
await $fetch(`/api/users/${id}`, { method: "DELETE" }) |
|||
await refresh() |
|||
} catch (err: any) { |
|||
alert(err?.data?.statusMessage || "删除失败") |
|||
} finally { |
|||
deletingId.value = null |
|||
} |
|||
} |
|||
|
|||
async function handleCreate() { |
|||
createError.value = "" |
|||
if (!createForm.value.username || !createForm.value.password) { |
|||
createError.value = "用户名和密码不能为空" |
|||
return |
|||
} |
|||
createLoading.value = true |
|||
try { |
|||
await $fetch("/api/users", { |
|||
method: "POST", |
|||
body: createForm.value, |
|||
}) |
|||
showCreateModal.value = false |
|||
createForm.value = { username: "", password: "", email: "", role: "user" } |
|||
currentPage.value = 1 |
|||
await refresh() |
|||
} catch (err: any) { |
|||
createError.value = err?.data?.statusMessage || "创建失败" |
|||
} finally { |
|||
createLoading.value = false |
|||
} |
|||
} |
|||
|
|||
function openCreate() { |
|||
createError.value = "" |
|||
createForm.value = { username: "", password: "", email: "", role: "user" } |
|||
showCreateModal.value = true |
|||
} |
|||
|
|||
function closeModal() { |
|||
showCreateModal.value = false |
|||
} |
|||
|
|||
function goToPage(page: number) { |
|||
if (page < 1 || page > totalPages.value) return |
|||
currentPage.value = page |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div> |
|||
dsada |
|||
<div class="users-page"> |
|||
<header class="page-header"> |
|||
<div class="page-header-content"> |
|||
<h1 class="page-title">用户管理</h1> |
|||
<p class="page-subtitle">管理系统用户账户</p> |
|||
</div> |
|||
<div class="header-actions"> |
|||
<div class="search-box"> |
|||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
|||
<circle cx="11" cy="11" r="8" /> |
|||
<path d="M21 21l-4.35-4.35" stroke-linecap="round" /> |
|||
</svg> |
|||
<input |
|||
v-model="searchQuery" |
|||
type="text" |
|||
placeholder="搜索用户名、邮箱..." |
|||
class="search-input" |
|||
@keyup.enter="currentPage = 1" |
|||
/> |
|||
</div> |
|||
<button class="btn-primary" @click="openCreate"> |
|||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|||
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" /> |
|||
<line x1="5" y1="12" x2="19" y2="12" stroke-linecap="round" /> |
|||
</svg> |
|||
新增用户 |
|||
</button> |
|||
</div> |
|||
</header> |
|||
|
|||
<!-- Stats bar --> |
|||
<div class="stats-grid"> |
|||
<div class="stat-card"> |
|||
<div class="stat-label">用户总数</div> |
|||
<div class="stat-value">{{ totalUsers }}</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="stat-label">管理员</div> |
|||
<div class="stat-value"> |
|||
{{ userList.filter((u) => u.role === "admin").length }} |
|||
</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="stat-label">已禁用</div> |
|||
<div class="stat-value"> |
|||
{{ userList.filter((u) => u.status === "disabled").length }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- User table --> |
|||
<div class="table-card"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th class="text-left">用户</th> |
|||
<th class="text-left">邮箱</th> |
|||
<th class="text-left">角色</th> |
|||
<th class="text-left">状态</th> |
|||
<th class="text-left">注册时间</th> |
|||
<th class="text-right">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="pending"> |
|||
<td colspan="6" class="text-center">加载中...</td> |
|||
</tr> |
|||
<tr v-else-if="userList.length === 0"> |
|||
<td colspan="6" class="text-center">暂无用户</td> |
|||
</tr> |
|||
<tr v-for="u in userList" :key="u.id" class="table-row"> |
|||
<td class="user-cell"> |
|||
<div class="user-info"> |
|||
<div class="user-avatar"> |
|||
<img v-if="u.avatar" :src="u.avatar" :alt="u.username" /> |
|||
<span v-else class="avatar-placeholder"> |
|||
{{ u.username.charAt(0).toUpperCase() }} |
|||
</span> |
|||
</div> |
|||
<div class="user-details"> |
|||
<span class="user-name">{{ u.nickname || u.username }}</span> |
|||
<span class="user-username">@{{ u.username }}</span> |
|||
</div> |
|||
</div> |
|||
</td> |
|||
<td class="email-cell">{{ u.email || "-" }}</td> |
|||
<td class="role-cell"> |
|||
<span :class="['role-badge', roleBadge(u.role).class]"> |
|||
{{ roleBadge(u.role).label }} |
|||
</span> |
|||
</td> |
|||
<td class="status-cell"> |
|||
<span :class="['status-badge', statusBadge(u.status).class]"> |
|||
{{ statusBadge(u.status).label }} |
|||
</span> |
|||
</td> |
|||
<td class="date-cell">{{ formatDate(u.createdAt) }}</td> |
|||
<td class="actions-cell"> |
|||
<button |
|||
class="action-btn action-btn-danger" |
|||
:disabled="deletingId === u.id" |
|||
@click="handleDelete(u.id)" |
|||
> |
|||
{{ deletingId === u.id ? "删除中..." : "删除" }} |
|||
</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
|
|||
<!-- Pagination --> |
|||
<div v-if="totalPages > 1" class="pagination"> |
|||
<button |
|||
class="page-btn" |
|||
:disabled="currentPage === 1" |
|||
@click="goToPage(currentPage - 1)" |
|||
> |
|||
上一页 |
|||
</button> |
|||
<div class="page-info"> |
|||
第 {{ currentPage }} / {{ totalPages }} 页,共 {{ totalUsers }} 条 |
|||
</div> |
|||
<button |
|||
class="page-btn" |
|||
:disabled="currentPage === totalPages" |
|||
@click="goToPage(currentPage + 1)" |
|||
> |
|||
下一页 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Create Modal --> |
|||
<Teleport to="body"> |
|||
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeModal"> |
|||
<div class="modal-card"> |
|||
<div class="modal-header"> |
|||
<h3 class="modal-title">新增用户</h3> |
|||
<button class="modal-close" @click="closeModal"> |
|||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|||
<line x1="18" y1="6" x2="6" y2="18" stroke-linecap="round" /> |
|||
<line x1="6" y1="6" x2="18" y2="18" stroke-linecap="round" /> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
<form class="modal-body" @submit.prevent="handleCreate"> |
|||
<div v-if="createError" class="form-error">{{ createError }}</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">用户名</label> |
|||
<input |
|||
v-model="createForm.username" |
|||
type="text" |
|||
class="form-input" |
|||
placeholder="3-20个字符,字母数字下划线" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">密码</label> |
|||
<input |
|||
v-model="createForm.password" |
|||
type="password" |
|||
class="form-input" |
|||
placeholder="至少6位" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">邮箱(选填)</label> |
|||
<input |
|||
v-model="createForm.email" |
|||
type="email" |
|||
class="form-input" |
|||
placeholder="example@domain.com" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label class="form-label">角色</label> |
|||
<select v-model="createForm.role" class="form-select"> |
|||
<option value="user">普通用户</option> |
|||
<option value="admin">管理员</option> |
|||
</select> |
|||
</div> |
|||
<div class="modal-footer"> |
|||
<button type="button" class="btn-secondary" @click="closeModal"> |
|||
取消 |
|||
</button> |
|||
<button type="submit" class="btn-primary" :disabled="createLoading"> |
|||
{{ createLoading ? "创建中..." : "创建" }} |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</Teleport> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.users-page { |
|||
padding: 40px; |
|||
} |
|||
|
|||
.page-header { |
|||
display: flex; |
|||
align-items: flex-start; |
|||
justify-content: space-between; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.page-title { |
|||
font-family: var(--font-display); |
|||
font-size: 32px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
letter-spacing: -0.3px; |
|||
margin: 0 0 6px 0; |
|||
} |
|||
|
|||
.page-subtitle { |
|||
font-size: 15px; |
|||
color: var(--color-muted); |
|||
margin: 0; |
|||
} |
|||
|
|||
.header-actions { |
|||
display: flex; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.search-box { |
|||
position: relative; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.search-icon { |
|||
position: absolute; |
|||
left: 12px; |
|||
width: 18px; |
|||
height: 18px; |
|||
color: var(--color-muted); |
|||
pointer-events: none; |
|||
} |
|||
|
|||
.search-input { |
|||
padding: 10px 14px 10px 40px; |
|||
background: var(--color-surface-card); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
color: var(--color-ink); |
|||
width: 240px; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.search-input::placeholder { |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.search-input:focus { |
|||
outline: none; |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15); |
|||
} |
|||
|
|||
.btn-primary { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
padding: 10px 18px; |
|||
background: var(--color-primary); |
|||
color: var(--color-on-primary); |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
cursor: pointer; |
|||
transition: background 0.15s ease; |
|||
} |
|||
|
|||
.btn-primary:hover { |
|||
background: var(--color-primary-active); |
|||
} |
|||
|
|||
.btn-primary:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.btn-primary svg { |
|||
width: 16px; |
|||
height: 16px; |
|||
} |
|||
|
|||
.stats-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(3, 1fr); |
|||
gap: 16px; |
|||
margin-bottom: 32px; |
|||
} |
|||
|
|||
.stat-card { |
|||
background: var(--color-surface-card); |
|||
border-radius: 12px; |
|||
padding: 20px; |
|||
} |
|||
|
|||
.stat-label { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
font-weight: 500; |
|||
margin-bottom: 4px; |
|||
} |
|||
|
|||
.stat-value { |
|||
font-family: var(--font-display); |
|||
font-size: 28px; |
|||
font-weight: 400; |
|||
color: var(--color-ink); |
|||
line-height: 1; |
|||
} |
|||
|
|||
.table-card { |
|||
background: var(--color-surface-card); |
|||
border-radius: 12px; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.data-table { |
|||
width: 100%; |
|||
border-collapse: collapse; |
|||
} |
|||
|
|||
.data-table th { |
|||
padding: 14px 16px; |
|||
font-size: 12px; |
|||
font-weight: 600; |
|||
color: var(--color-muted); |
|||
text-transform: uppercase; |
|||
letter-spacing: 0.05em; |
|||
background: var(--color-surface-soft); |
|||
border-bottom: 1px solid var(--color-hairline); |
|||
text-align: left; |
|||
} |
|||
|
|||
.data-table td { |
|||
padding: 14px 16px; |
|||
font-size: 14px; |
|||
color: var(--color-body-strong); |
|||
border-bottom: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.table-row:last-child td { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.table-row:hover td { |
|||
background: var(--color-surface-soft); |
|||
} |
|||
|
|||
.user-cell { |
|||
min-width: 200px; |
|||
} |
|||
|
|||
.user-info { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.user-avatar { |
|||
width: 36px; |
|||
height: 36px; |
|||
border-radius: 50%; |
|||
overflow: hidden; |
|||
flex-shrink: 0; |
|||
background: var(--color-surface-soft); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.user-avatar img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: cover; |
|||
} |
|||
|
|||
.avatar-placeholder { |
|||
font-size: 14px; |
|||
font-weight: 600; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.user-details { |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.user-name { |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
font-size: 14px; |
|||
} |
|||
|
|||
.user-username { |
|||
font-size: 12px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.email-cell { |
|||
color: var(--color-muted); |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.date-cell { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.role-badge { |
|||
display: inline-block; |
|||
padding: 3px 10px; |
|||
border-radius: 9999px; |
|||
font-size: 12px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.role-admin { |
|||
background: rgba(204, 120, 92, 0.15); |
|||
color: var(--color-primary); |
|||
} |
|||
|
|||
.role-user { |
|||
background: rgba(93, 184, 166, 0.15); |
|||
color: var(--color-accent-teal); |
|||
} |
|||
|
|||
.status-badge { |
|||
display: inline-block; |
|||
padding: 3px 10px; |
|||
border-radius: 9999px; |
|||
font-size: 12px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.status-active { |
|||
background: rgba(93, 184, 166, 0.15); |
|||
color: var(--color-accent-teal); |
|||
} |
|||
|
|||
.status-disabled { |
|||
background: var(--color-hairline); |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.actions-cell { |
|||
text-align: right; |
|||
} |
|||
|
|||
.action-btn { |
|||
padding: 6px 12px; |
|||
font-size: 12px; |
|||
font-weight: 500; |
|||
color: var(--color-muted); |
|||
background: transparent; |
|||
border: none; |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.action-btn:hover { |
|||
background: var(--color-hairline); |
|||
color: var(--color-ink); |
|||
} |
|||
|
|||
.action-btn:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.action-btn-danger:hover { |
|||
background: rgba(198, 69, 69, 0.1); |
|||
color: #c64545; |
|||
} |
|||
|
|||
.pagination { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 16px; |
|||
padding: 16px; |
|||
border-top: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.page-btn { |
|||
padding: 8px 16px; |
|||
font-size: 13px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
background: var(--color-surface-soft); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.page-btn:hover:not(:disabled) { |
|||
background: var(--color-hairline); |
|||
} |
|||
|
|||
.page-btn:disabled { |
|||
opacity: 0.4; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.page-info { |
|||
font-size: 13px; |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
/* Modal */ |
|||
.modal-overlay { |
|||
position: fixed; |
|||
inset: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
z-index: 1000; |
|||
} |
|||
|
|||
.modal-card { |
|||
background: var(--color-canvas); |
|||
border-radius: 12px; |
|||
width: 100%; |
|||
max-width: 440px; |
|||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.modal-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 20px 24px; |
|||
border-bottom: 1px solid var(--color-hairline); |
|||
} |
|||
|
|||
.modal-title { |
|||
font-size: 18px; |
|||
font-weight: 500; |
|||
color: var(--color-ink); |
|||
margin: 0; |
|||
} |
|||
|
|||
.modal-close { |
|||
width: 32px; |
|||
height: 32px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: transparent; |
|||
border: none; |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
color: var(--color-muted); |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.modal-close:hover { |
|||
background: var(--color-surface-soft); |
|||
color: var(--color-ink); |
|||
} |
|||
|
|||
.modal-close svg { |
|||
width: 18px; |
|||
height: 18px; |
|||
} |
|||
|
|||
.modal-body { |
|||
padding: 24px; |
|||
} |
|||
|
|||
.form-error { |
|||
padding: 10px 14px; |
|||
background: rgba(198, 69, 69, 0.1); |
|||
border: 1px solid rgba(198, 69, 69, 0.2); |
|||
border-radius: 6px; |
|||
color: #c64545; |
|||
font-size: 13px; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.form-group { |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.form-label { |
|||
display: block; |
|||
font-size: 13px; |
|||
font-weight: 500; |
|||
color: var(--color-body-strong); |
|||
margin-bottom: 6px; |
|||
} |
|||
|
|||
.form-input, |
|||
.form-select { |
|||
width: 100%; |
|||
padding: 10px 14px; |
|||
background: var(--color-canvas); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
color: var(--color-ink); |
|||
transition: all 0.15s ease; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.form-input:focus, |
|||
.form-select:focus { |
|||
outline: none; |
|||
border-color: var(--color-primary); |
|||
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15); |
|||
} |
|||
|
|||
.form-input::placeholder { |
|||
color: var(--color-muted); |
|||
} |
|||
|
|||
.modal-footer { |
|||
display: flex; |
|||
gap: 12px; |
|||
justify-content: flex-end; |
|||
padding-top: 8px; |
|||
} |
|||
|
|||
.btn-secondary { |
|||
padding: 10px 18px; |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
color: var(--color-body-strong); |
|||
background: var(--color-surface-soft); |
|||
border: 1px solid var(--color-hairline); |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: all 0.15s ease; |
|||
} |
|||
|
|||
.btn-secondary:hover { |
|||
background: var(--color-hairline); |
|||
} |
|||
|
|||
@media (max-width: 1024px) { |
|||
.stats-grid { |
|||
grid-template-columns: repeat(2, 1fr); |
|||
} |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.users-page { |
|||
padding: 24px; |
|||
} |
|||
|
|||
.page-header { |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.header-actions { |
|||
flex-direction: column; |
|||
width: 100%; |
|||
} |
|||
|
|||
.search-input { |
|||
width: 100%; |
|||
} |
|||
|
|||
.stats-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
</style> |
|||
|
|||
Binary file not shown.
@ -0,0 +1,36 @@ |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { users, sessions } from "drizzle-pkg/lib/schema/auth"; |
|||
import { eq } from "drizzle-orm"; |
|||
import log4js from "logger"; |
|||
|
|||
const logger = log4js.getLogger("USERS"); |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const id = Number(event.context.params?.id); |
|||
|
|||
if (!id || isNaN(id)) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "无效的用户ID", |
|||
}); |
|||
} |
|||
|
|||
const [user] = await dbGlobal |
|||
.select({ id: users.id, username: users.username }) |
|||
.from(users) |
|||
.where(eq(users.id, id)); |
|||
|
|||
if (!user) { |
|||
throw createError({ |
|||
statusCode: 404, |
|||
statusMessage: "用户不存在", |
|||
}); |
|||
} |
|||
|
|||
await dbGlobal.delete(sessions).where(eq(sessions.userId, id)); |
|||
await dbGlobal.delete(users).where(eq(users.id, id)); |
|||
|
|||
logger.info("user deleted by admin: %s (id: %d)", user.username, id); |
|||
|
|||
return R.success({ message: "用户已删除" }); |
|||
}); |
|||
@ -0,0 +1,50 @@ |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { users } from "drizzle-pkg/lib/schema/auth"; |
|||
import { count, desc, like, or } from "drizzle-orm"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const query = getQuery(event); |
|||
const page = query.page ? Number(query.page) : 1; |
|||
const pageSize = query.pageSize ? Number(query.pageSize) : 10; |
|||
const search = query.search as string | undefined; |
|||
|
|||
const offset = (page - 1) * pageSize; |
|||
const searchPattern = search ? `%${search}%` : undefined; |
|||
|
|||
const [totalResult] = await dbGlobal |
|||
.select({ total: count() }) |
|||
.from(users); |
|||
|
|||
const list = await dbGlobal |
|||
.select({ |
|||
id: users.id, |
|||
username: users.username, |
|||
email: users.email, |
|||
nickname: users.nickname, |
|||
avatar: users.avatar, |
|||
role: users.role, |
|||
status: users.status, |
|||
createdAt: users.createdAt, |
|||
}) |
|||
.from(users) |
|||
.where( |
|||
searchPattern |
|||
? or( |
|||
like(users.username, searchPattern), |
|||
like(users.email, searchPattern), |
|||
like(users.nickname, searchPattern), |
|||
) |
|||
: undefined, |
|||
) |
|||
.orderBy(desc(users.createdAt)) |
|||
.limit(pageSize) |
|||
.offset(offset); |
|||
|
|||
return R.success({ |
|||
list, |
|||
total: totalResult?.total ?? 0, |
|||
page, |
|||
pageSize, |
|||
totalPages: Math.ceil((totalResult?.total ?? 0) / pageSize), |
|||
}); |
|||
}); |
|||
@ -0,0 +1,83 @@ |
|||
import { hash } from "bcryptjs"; |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { users } from "drizzle-pkg/lib/schema/auth"; |
|||
import { isUniqueConflictOnField } from "#server/utils/db-unique-constraint"; |
|||
import log4js from "logger"; |
|||
|
|||
const logger = log4js.getLogger("USERS"); |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const body = await readBody(event); |
|||
|
|||
if (!body?.username || !body?.password) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "用户名和密码不能为空", |
|||
}); |
|||
} |
|||
|
|||
const username = body.username.trim(); |
|||
const password = body.password; |
|||
const email = body.email?.trim() || undefined; |
|||
const role = body.role === "admin" ? "admin" : "user"; |
|||
|
|||
if (username.length < 3 || username.length > 20) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "用户名长度需在 3-20 个字符之间", |
|||
}); |
|||
} |
|||
|
|||
if (!/^[a-zA-Z0-9_]+$/.test(username)) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "用户名只能包含字母、数字和下划线", |
|||
}); |
|||
} |
|||
|
|||
if (password.length < 6) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "密码长度至少 6 位", |
|||
}); |
|||
} |
|||
|
|||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: "邮箱格式不正确", |
|||
}); |
|||
} |
|||
|
|||
const passwordHash = await hash(password, 12); |
|||
|
|||
try { |
|||
const [newUser] = await dbGlobal |
|||
.insert(users) |
|||
.values({ |
|||
username, |
|||
password: passwordHash, |
|||
email: email || null, |
|||
role, |
|||
}) |
|||
.returning({ |
|||
id: users.id, |
|||
username: users.username, |
|||
email: users.email, |
|||
role: users.role, |
|||
nickname: users.nickname, |
|||
avatar: users.avatar, |
|||
}); |
|||
|
|||
logger.info("user created by admin: %s (role: %s)", username, role); |
|||
return R.success(newUser); |
|||
} catch (err) { |
|||
if (isUniqueConflictOnField(err, "username")) { |
|||
throw createError({ |
|||
statusCode: 409, |
|||
statusMessage: "用户名已存在", |
|||
}); |
|||
} |
|||
throw err; |
|||
} |
|||
}); |
|||
Loading…
Reference in new issue