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> |
<template> |
||||
<div> |
<div class="users-page"> |
||||
dsada |
<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> |
</div> |
||||
</template> |
</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