Browse Source

feat: 添加用户管理功能,包括用户创建、删除和查询接口

shadcn-as
npmrun 2 weeks ago
parent
commit
aeaf2a1ad9
  1. 3
      AGENTS.md
  2. 807
      app/pages/admin/users/index.vue
  3. BIN
      packages/drizzle-pkg/db.sqlite
  4. 36
      server/api/users/[id].delete.ts
  5. 50
      server/api/users/index.get.ts
  6. 83
      server/api/users/index.post.ts

3
AGENTS.md

@ -4,7 +4,8 @@
- 安装依赖时,必须写死依赖的版本,禁止任何升级的可能,如存在需要升级的场景,必须手动执行安装新版本命令 - 安装依赖时,必须写死依赖的版本,禁止任何升级的可能,如存在需要升级的场景,必须手动执行安装新版本命令
- 新建的mono包必须在根目录安装 - 新建的mono包必须在根目录安装
- 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析 - 使用nuxt时遇到不清楚的,必须先加载nuxt-remote查询文档再进行分析
- 能够用toast提示的尽量用toast
## 设计方案 ## 设计方案
开发页面与组件时必须参考 @DESIGN.md 开发页面与组件时必须参考 DESIGN.md

807
app/pages/admin/users/index.vue

@ -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>

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

36
server/api/users/[id].delete.ts

@ -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: "用户已删除" });
});

50
server/api/users/index.get.ts

@ -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),
});
});

83
server/api/users/index.post.ts

@ -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…
Cancel
Save