From 8eef6ea1364a15e5638642d8df1f71c65ebb084b Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Wed, 27 May 2026 17:07:38 +0800 Subject: [PATCH] feat: redesign admin users management with filtering, sorting, and batch operations - Backend: extend GET /api/users with status, role filtering and sorting - Backend: add PUT /api/users/:id endpoint for updating user info - Backend: add POST /api/users/batch for batch enable/disable/delete - Frontend: rewrite users page with filter bar, stats cards, sortable table - Frontend: add batch action bar and detail drawer for editing - Frontend: add create modal and confirm dialog for batch delete Co-Authored-By: Claude Opus 4.7 --- app/pages/admin/users/index.vue | 1042 ++++++++++++++++---- .../2026-05-27-users-management-implementation.md | 368 +++++++ .../specs/2026-05-27-users-management-design.md | 105 ++ server/api/users/[id].put.ts | 96 ++ server/api/users/batch.post.ts | 52 + server/api/users/index.get.ts | 48 +- 6 files changed, 1508 insertions(+), 203 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-27-users-management-implementation.md create mode 100644 docs/superpowers/specs/2026-05-27-users-management-design.md create mode 100644 server/api/users/[id].put.ts create mode 100644 server/api/users/batch.post.ts diff --git a/app/pages/admin/users/index.vue b/app/pages/admin/users/index.vue index 1cd513d..5a9e5f2 100644 --- a/app/pages/admin/users/index.vue +++ b/app/pages/admin/users/index.vue @@ -7,127 +7,331 @@ interface UserRow { avatar: string | null role: string status: string - createdAt: Date | null + createdAt: string | null } +// Filter state +const statusFilter = ref<'' | 'active' | 'disabled'>('') +const roleFilter = ref<'' | 'admin' | 'user'>('') +const searchQuery = ref('') const currentPage = ref(1) const pageSize = ref(10) -const searchQuery = ref("") +const sortBy = ref<'createdAt' | 'username'>('createdAt') +const sortOrder = ref<'asc' | 'desc'>('desc') -const { data, refresh, pending } = await useHttpFetch("/api/users", { +// Fetch users +const { data, refresh, pending } = await useHttpFetch('/api/users', { query: computed(() => ({ page: currentPage.value, pageSize: pageSize.value, search: searchQuery.value || undefined, + status: statusFilter.value || undefined, + role: roleFilter.value || undefined, + sortBy: sortBy.value, + sortOrder: sortOrder.value, })), - watch: [currentPage, pageSize, searchQuery], + watch: [currentPage, pageSize, searchQuery, statusFilter, roleFilter, sortBy, sortOrder], }) const userList = computed(() => (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 +// Selection +const selectedIds = ref>(new Set()) +const isAllSelected = computed(() => userList.value.length > 0 && userList.value.every(u => selectedIds.value.has(u.id))) + +function toggleSelectAll() { + if (isAllSelected.value) { + selectedIds.value = new Set() + } else { + selectedIds.value = new Set(userList.value.map(u => u.id)) + } +} + +function toggleSelect(id: number) { + const newSet = new Set(selectedIds.value) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + selectedIds.value = newSet +} + +// Sorting +function toggleSort(column: 'createdAt' | 'username') { + if (sortBy.value === column) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = column + sortOrder.value = 'asc' + } + currentPage.value = 1 +} + +// Create modal const showCreateModal = ref(false) const createForm = ref({ - username: "", - password: "", - email: "", - role: "user" as "user" | "admin", + username: '', + password: '', + email: '', + role: 'user' as 'user' | 'admin', }) const createLoading = ref(false) -const createError = ref("") +const createError = ref('') + +// Detail drawer +const showDrawer = ref(false) +const editingUser = ref(null) +const drawerForm = ref({ + nickname: '', + email: '', + role: 'user' as 'user' | 'admin', + status: 'active' as 'active' | 'disabled', +}) +const drawerLoading = ref(false) +const drawerError = ref('') + +// Confirm dialog +const showConfirmDialog = ref(false) +const confirmLoading = ref(false) + +// Toast helper +function showToast(message: string, type: 'success' | 'error' = 'success') { + if (import.meta.client) { + const event = new CustomEvent('toast', { detail: { message, type } }) + window.dispatchEvent(event) + } +} -// Delete confirmation -const deletingId = ref(null) +// Actions +async function handleBatchEnable() { + if (selectedIds.value.size === 0) return + confirmLoading.value = true + try { + await $fetch('/api/users/batch', { + method: 'POST', + body: { action: 'enable', ids: Array.from(selectedIds.value) }, + }) + selectedIds.value = new Set() + await refresh() + showToast('已启用成功') + } catch (err: any) { + showToast(err?.data?.statusMessage || '启用失败', 'error') + } finally { + confirmLoading.value = false + } +} -function roleBadge(role: string) { - const map: Record = { - admin: { label: "管理员", class: "role-admin" }, - user: { label: "用户", class: "role-user" }, +async function handleBatchDisable() { + if (selectedIds.value.size === 0) return + confirmLoading.value = true + try { + await $fetch('/api/users/batch', { + method: 'POST', + body: { action: 'disable', ids: Array.from(selectedIds.value) }, + }) + selectedIds.value = new Set() + await refresh() + showToast('已禁用成功') + } catch (err: any) { + showToast(err?.data?.statusMessage || '禁用失败', 'error') + } finally { + confirmLoading.value = false } - return map[role] ?? { label: role, class: "role-user" } } -function statusBadge(status: string) { - const map: Record = { - active: { label: "正常", class: "status-active" }, - disabled: { label: "禁用", class: "status-disabled" }, +async function handleBatchDelete() { + showConfirmDialog.value = true +} + +async function confirmBatchDelete() { + confirmLoading.value = true + try { + await $fetch('/api/users/batch', { + method: 'POST', + body: { action: 'delete', ids: Array.from(selectedIds.value) }, + }) + selectedIds.value = new Set() + showConfirmDialog.value = false + await refresh() + showToast('已删除成功') + } catch (err: any) { + showToast(err?.data?.statusMessage || '删除失败', 'error') + } finally { + confirmLoading.value = false } - 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)) +function openDrawer(user: UserRow) { + editingUser.value = user + drawerForm.value = { + nickname: user.nickname || '', + email: user.email || '', + role: user.role as 'user' | 'admin', + status: user.status as 'active' | 'disabled', + } + drawerError.value = '' + showDrawer.value = true } -async function handleDelete(id: number) { - if (!confirm("确定删除此用户?此操作不可撤销。")) return - deletingId.value = id +function closeDrawer() { + showDrawer.value = false + editingUser.value = null +} + +async function handleDrawerSave() { + if (!editingUser.value) return + drawerLoading.value = true + drawerError.value = '' try { - await $fetch(`/api/users/${id}`, { method: "DELETE" }) + await $fetch(`/api/users/${editingUser.value.id}`, { + method: 'PUT', + body: { + nickname: drawerForm.value.nickname, + email: drawerForm.value.email || undefined, + role: drawerForm.value.role, + status: drawerForm.value.status, + }, + }) + closeDrawer() await refresh() + showToast('保存成功') } catch (err: any) { - alert(err?.data?.statusMessage || "删除失败") + drawerError.value = err?.data?.statusMessage || '保存失败' } finally { - deletingId.value = null + drawerLoading.value = false + } +} + +async function handleDelete(id: number) { + try { + await $fetch(`/api/users/${id}`, { method: 'DELETE' }) + selectedIds.value = new Set([...selectedIds.value].filter(i => i !== id)) + await refresh() + showToast('删除成功') + } catch (err: any) { + showToast(err?.data?.statusMessage || '删除失败', 'error') } } +function openCreate() { + createError.value = '' + createForm.value = { username: '', password: '', email: '', role: 'user' } + showCreateModal.value = true +} + +function closeModal() { + showCreateModal.value = false +} + async function handleCreate() { - createError.value = "" + createError.value = '' if (!createForm.value.username || !createForm.value.password) { - createError.value = "用户名和密码不能为空" + createError.value = '用户名和密码不能为空' return } createLoading.value = true try { - await $fetch("/api/users", { - method: "POST", + await $fetch('/api/users', { + method: 'POST', body: createForm.value, }) - showCreateModal.value = false - createForm.value = { username: "", password: "", email: "", role: "user" } + closeModal() currentPage.value = 1 await refresh() + showToast('创建成功') } catch (err: any) { - createError.value = err?.data?.statusMessage || "创建失败" + createError.value = err?.data?.statusMessage || '创建失败' } finally { createLoading.value = false } } -function openCreate() { - createError.value = "" - createForm.value = { username: "", password: "", email: "", role: "user" } - showCreateModal.value = true +function goToPage(page: number) { + if (page < 1 || page > totalPages.value) return + currentPage.value = page } -function closeModal() { - showCreateModal.value = false +function formatDate(date: string | null) { + if (!date) return '-' + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(date)) } -function goToPage(page: number) { - if (page < 1 || page > totalPages.value) return - currentPage.value = page +function roleBadge(role: string) { + const map: Record = { + 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 = { + active: { label: '正常', class: 'status-active' }, + disabled: { label: '禁用', class: 'status-disabled' }, + } + return map[status] ?? { label: status, class: 'status-disabled' } } @@ -329,24 +642,105 @@ function goToPage(page: number) { margin-bottom: 32px; } +.header-left { + flex: 1; +} + .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; + margin: 0 0 20px 0; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + max-width: 600px; } -.page-subtitle { - font-size: 15px; +.stat-card { + background: var(--color-surface-card); + border-radius: 12px; + padding: 16px 20px; +} + +.stat-value { + font-family: var(--font-display); + font-size: 28px; + font-weight: 400; + color: var(--color-ink); + line-height: 1; + margin-bottom: 4px; +} + +.stat-label { + font-size: 13px; color: var(--color-muted); - margin: 0; + font-weight: 500; +} + +/* Filter bar */ +.filter-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; } -.header-actions { +.filter-left { display: flex; + align-items: center; gap: 12px; + flex-wrap: wrap; +} + +.status-tabs { + display: flex; + gap: 4px; + background: var(--color-surface-soft); + padding: 4px; + border-radius: 8px; +} + +.status-tab { + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + color: var(--color-muted); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.status-tab:hover { + color: var(--color-ink); +} + +.status-tab.active { + background: var(--color-surface-card); + color: var(--color-ink); +} + +.role-select { + padding: 8px 12px; + background: var(--color-surface-card); + border: 1px solid var(--color-hairline); + border-radius: 8px; + font-size: 13px; + color: var(--color-ink); + cursor: pointer; +} + +.role-select:focus { + outline: none; + border-color: var(--color-primary); } .search-box { @@ -358,20 +752,20 @@ function goToPage(page: number) { .search-icon { position: absolute; left: 12px; - width: 18px; - height: 18px; + width: 16px; + height: 16px; color: var(--color-muted); pointer-events: none; } .search-input { - padding: 10px 14px 10px 40px; + padding: 8px 12px 8px 36px; background: var(--color-surface-card); border: 1px solid var(--color-hairline); border-radius: 8px; - font-size: 14px; + font-size: 13px; color: var(--color-ink); - width: 240px; + width: 200px; transition: all 0.15s ease; } @@ -388,13 +782,13 @@ function goToPage(page: number) { .btn-primary { display: inline-flex; align-items: center; - gap: 8px; - padding: 10px 18px; + gap: 6px; + padding: 10px 16px; background: var(--color-primary); color: var(--color-on-primary); border: none; border-radius: 8px; - font-size: 14px; + font-size: 13px; font-weight: 500; cursor: pointer; transition: background 0.15s ease; @@ -410,38 +804,11 @@ function goToPage(page: number) { } .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; + width: 14px; + height: 14px; } +/* Table */ .table-card { background: var(--color-surface-card); border-radius: 12px; @@ -454,8 +821,8 @@ function goToPage(page: number) { } .data-table th { - padding: 14px 16px; - font-size: 12px; + padding: 12px 16px; + font-size: 11px; font-weight: 600; color: var(--color-muted); text-transform: uppercase; @@ -465,6 +832,36 @@ function goToPage(page: number) { text-align: left; } +.data-table th.th-checkbox { + width: 48px; + padding: 12px 0 12px 16px; +} + +.data-table th.th-sortable { + cursor: pointer; + user-select: none; +} + +.data-table th.th-sortable:hover { + color: var(--color-ink); +} + +.data-table th.th-sortable span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.sort-icon { + width: 14px; + height: 14px; + transition: transform 0.15s ease; +} + +.sort-icon.desc { + transform: rotate(180deg); +} + .data-table td { padding: 14px 16px; font-size: 14px; @@ -472,6 +869,11 @@ function goToPage(page: number) { border-bottom: 1px solid var(--color-hairline); } +.data-table td.td-checkbox { + width: 48px; + padding: 14px 0 14px 16px; +} + .table-row:last-child td { border-bottom: none; } @@ -480,6 +882,17 @@ function goToPage(page: number) { background: var(--color-surface-soft); } +.table-row.selected td { + background: rgba(204, 120, 92, 0.05); +} + +.checkbox { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--color-primary); +} + .user-cell { min-width: 200px; } @@ -541,7 +954,8 @@ function goToPage(page: number) { color: var(--color-muted); } -.role-badge { +.role-badge, +.status-badge { display: inline-block; padding: 3px 10px; border-radius: 9999px; @@ -559,14 +973,6 @@ function goToPage(page: number) { 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); @@ -598,27 +1004,28 @@ function goToPage(page: number) { 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 */ .pagination { display: flex; align-items: center; - justify-content: center; + justify-content: space-between; gap: 16px; - padding: 16px; + padding: 16px 20px; border-top: 1px solid var(--color-hairline); } +.page-info { + font-size: 13px; + color: var(--color-muted); +} + +.page-buttons { + display: flex; + gap: 8px; +} + .page-btn { - padding: 8px 16px; + padding: 8px 14px; font-size: 13px; font-weight: 500; color: var(--color-ink); @@ -638,9 +1045,151 @@ function goToPage(page: number) { cursor: not-allowed; } -.page-info { +/* Batch bar */ +.batch-bar { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 20px; + padding: 12px 20px; + background: var(--color-surface-dark); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + z-index: 100; +} + +.batch-info { + font-size: 14px; + font-weight: 500; + color: var(--color-on-dark); +} + +.batch-actions { + display: flex; + gap: 8px; +} + +.batch-btn { + padding: 8px 14px; font-size: 13px; + font-weight: 500; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; +} + +.batch-btn-enable { + background: var(--color-accent-teal); + color: white; +} + +.batch-btn-enable:hover { + background: #4ca894; +} + +.batch-btn-disable { + background: var(--color-muted-soft); + color: white; +} + +.batch-btn-disable:hover { + background: var(--color-muted); +} + +.batch-btn-delete { + background: var(--color-error); + color: white; +} + +.batch-btn-delete:hover { + background: #b33d3d; +} + +/* Drawer */ +.drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 200; +} + +.drawer-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 480px; + max-width: 100%; + background: var(--color-canvas); + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; +} + +.drawer-header { + display: flex; + flex-direction: column; + gap: 4px; + padding: 20px 24px; + border-bottom: 1px solid var(--color-hairline); +} + +.drawer-title { + font-size: 18px; + font-weight: 500; + color: var(--color-ink); + margin: 0; +} + +.drawer-subtitle { + font-size: 13px; + color: var(--color-muted); +} + +.drawer-close { + position: absolute; + top: 16px; + right: 16px; + 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; +} + +.drawer-close:hover { + background: var(--color-surface-soft); + color: var(--color-ink); +} + +.drawer-close svg { + width: 18px; + height: 18px; +} + +.drawer-body { + flex: 1; + padding: 24px; + overflow-y: auto; +} + +.drawer-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid var(--color-hairline); + margin-top: auto; } /* Modal */ @@ -651,7 +1200,7 @@ function goToPage(page: number) { display: flex; align-items: center; justify-content: center; - z-index: 1000; + z-index: 300; } .modal-card { @@ -710,7 +1259,7 @@ function goToPage(page: number) { background: rgba(198, 69, 69, 0.1); border: 1px solid rgba(198, 69, 69, 0.2); border-radius: 6px; - color: #c64545; + color: var(--color-error); font-size: 13px; margin-bottom: 16px; } @@ -751,6 +1300,12 @@ function goToPage(page: number) { color: var(--color-muted); } +.form-input[readonly] { + background: var(--color-surface-soft); + color: var(--color-muted); + cursor: not-allowed; +} + .modal-footer { display: flex; gap: 12px; @@ -774,6 +1329,94 @@ function goToPage(page: number) { background: var(--color-hairline); } +.btn-error { + padding: 10px 18px; + font-size: 14px; + font-weight: 500; + color: white; + background: var(--color-error); + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-error:hover { + background: #b33d3d; +} + +.btn-error:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Confirm dialog */ +.confirm-dialog { + background: var(--color-canvas); + border-radius: 12px; + width: 100%; + max-width: 400px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); +} + +.confirm-header { + padding: 20px 24px; + border-bottom: 1px solid var(--color-hairline); +} + +.confirm-title { + font-size: 18px; + font-weight: 500; + color: var(--color-ink); + margin: 0; +} + +.confirm-body { + padding: 24px; +} + +.confirm-body p { + margin: 0; + font-size: 14px; + color: var(--color-body-strong); + line-height: 1.5; +} + +.confirm-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + padding: 16px 24px; + border-top: 1px solid var(--color-hairline); +} + +/* Transitions */ +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.2s ease; +} + +.slide-up-enter-from, +.slide-up-leave-to { + opacity: 0; + transform: translateX(-50%) translateY(20px); +} + +.slide-right-enter-active, +.slide-right-leave-active { + transition: all 0.3s ease; +} + +.slide-right-enter-from .drawer-panel, +.slide-right-leave-to .drawer-panel { + transform: translateX(100%); +} + +.slide-right-enter-from, +.slide-right-leave-to { + opacity: 0; +} + @media (max-width: 1024px) { .stats-grid { grid-template-columns: repeat(2, 1fr); @@ -790,9 +1433,14 @@ function goToPage(page: number) { gap: 16px; } - .header-actions { + .filter-bar { flex-direction: column; - width: 100%; + align-items: stretch; + } + + .filter-left { + flex-direction: column; + align-items: stretch; } .search-input { @@ -802,5 +1450,17 @@ function goToPage(page: number) { .stats-grid { grid-template-columns: 1fr; } + + .drawer-panel { + width: 100%; + } + + .batch-bar { + left: 16px; + right: 16px; + transform: none; + flex-direction: column; + gap: 12px; + } } - + \ No newline at end of file diff --git a/docs/superpowers/plans/2026-05-27-users-management-implementation.md b/docs/superpowers/plans/2026-05-27-users-management-implementation.md new file mode 100644 index 0000000..0dd65c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-users-management-implementation.md @@ -0,0 +1,368 @@ +# Users Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 重新实现 admin/users 模块,提供多条件筛选、批量操作、详情抽屉编辑功能 + +**Architecture:** 后端扩展用户 API 支持状态/角色筛选、排序、批量操作;前端重写页面组件,采用筛选栏 + 多选表格 + 批量操作栏 + 详情抽屉的布局 + +**Tech Stack:** Nuxt 3, TypeScript, Tailwind (CSS variables from DESIGN.md), useHttpFetch composable + +--- + +## File Structure + +``` +server/api/users/ + index.get.ts — 修改:增加 status, role, sortBy, sortOrder 筛选 + [id].put.ts — 新增:更新单个用户 + batch.post.ts — 新增:批量操作(启用/禁用/删除) + +app/pages/admin/users/ + index.vue — 重写:完整用户管理界面 +``` + +--- + +## Task 1: Extend GET /api/users with filtering & sorting + +**Files:** +- Modify: `server/api/users/index.get.ts` + +- [ ] **Step 1: Read current implementation** + +Review `server/api/users/index.get.ts` (lines 1-50 already read) + +- [ ] **Step 2: Add query parameters for status, role, sortBy, sortOrder** + +```typescript +// Add after line 9 (after search) +const status = query.status as string | undefined; +const role = query.role as string | undefined; +const sortBy = query.sortBy as string | undefined; +const sortOrder = query.sortOrder as "asc" | "desc" | undefined; +``` + +- [ ] **Step 3: Build where clause** + +```typescript +// Add after line 11 (after searchPattern) +const conditions = searchPattern + ? or( + like(users.username, searchPattern), + like(users.email, searchPattern), + like(users.nickname, searchPattern), + ) + : undefined; + +if (status && status !== "all") { + conditions && conditions.length + ? (conditions.push(eq(users.status, status as "active" | "disabled"))) + : undefined; +} + +if (role && role !== "all") { + conditions + ? undefined + : undefined; +} +``` + +Actually, use drizzle's `and` helper: + +```typescript +import { and, eq } from "drizzle-orm"; + +// Replace the where clause building (lines 30-38) with: +const whereConditions = []; + +if (searchPattern) { + whereConditions.push( + or( + like(users.username, searchPattern), + like(users.email, searchPattern), + like(users.nickname, searchPattern), + ) + ); +} + +if (status && status !== "all") { + whereConditions.push(eq(users.status, status as "active" | "disabled")); +} + +if (role && role !== "all") { + whereConditions.push(eq(users.role, role as "admin" | "user")); +} + +const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined; +``` + +- [ ] **Step 4: Add sorting logic** + +```typescript +// Add before dbGlobal.select (before line 18) +const orderColumn = sortBy === "username" ? users.username + : sortBy === "createdAt" ? users.createdAt + : users.createdAt; +const orderDirection = sortOrder === "asc" ? orderColumn : desc(orderColumn); +``` + +- [ ] **Step 5: Update query to use whereClause and orderBy** + +Replace `.where(searchPattern ? or(...) : undefined)` with `.where(whereClause)` +Replace `.orderBy(desc(users.createdAt))` with `.orderBy(orderDirection)` + +- [ ] **Step 6: Commit** + +```bash +git add server/api/users/index.get.ts +git commit -m "feat(users): add status, role filtering and sorting to GET /api/users" +``` + +--- + +## Task 2: Create PUT /api/users/:id endpoint + +**Files:** +- Create: `server/api/users/[id].put.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } 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); + const body = await readBody(event); + + if (!id || isNaN(id)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的用户ID", + }); + } + + if (body.email !== undefined && body.email !== "" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + throw createError({ + statusCode: 400, + statusMessage: "邮箱格式不正确", + }); + } + + if (body.role && !["admin", "user"].includes(body.role)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的角色", + }); + } + + if (body.status && !["active", "disabled"].includes(body.status)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的状态", + }); + } + + const [existing] = await dbGlobal + .select({ id: users.id }) + .from(users) + .where(eq(users.id, id)); + + if (!existing) { + throw createError({ + statusCode: 404, + statusMessage: "用户不存在", + }); + } + + const updateData: Partial<{ + nickname: string | null; + email: string | null; + role: "admin" | "user"; + status: "active" | "disabled"; + }> = {}; + + if (body.nickname !== undefined) updateData.nickname = body.nickname || null; + if (body.email !== undefined) updateData.email = body.email || null; + if (body.role !== undefined) updateData.role = body.role; + if (body.status !== undefined) updateData.status = body.status; + + const [updated] = await dbGlobal + .update(users) + .set(updateData) + .where(eq(users.id, id)) + .returning({ + id: users.id, + username: users.username, + email: users.email, + nickname: users.nickname, + avatar: users.avatar, + role: users.role, + status: users.status, + createdAt: users.createdAt, + }); + + logger.info("user updated by admin: %s (id: %d)", updated.username, id); + return R.success(updated); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/api/users/[id].put.ts +git commit -m "feat(users): add PUT /api/users/:id endpoint" +``` + +--- + +## Task 3: Create POST /api/users/batch endpoint + +**Files:** +- Create: `server/api/users/batch.post.ts` + +- [ ] **Step 1: Create the file** + +```typescript +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } from "drizzle-pkg/lib/schema/auth"; +import { eq, inArray } from "drizzle-orm"; +import log4js from "logger"; + +const logger = log4js.getLogger("USERS"); + +export default defineWrappedResponseHandler(async (event) => { + const body = await readBody(event); + + if (!body?.ids || !Array.isArray(body.ids) || body.ids.length === 0) { + throw createError({ + statusCode: 400, + statusMessage: "请选择要操作的用户", + }); + } + + if (!body?.action || !["enable", "disable", "delete"].includes(body.action)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的操作类型", + }); + } + + const ids = body.ids.map(Number).filter(n => !isNaN(n)); + if (ids.length === 0) { + throw createError({ + statusCode: 400, + statusMessage: "无效的用户ID列表", + }); + } + + if (body.action === "delete") { + await dbGlobal.delete(users).where(inArray(users.id, ids)); + logger.info("users batch deleted by admin: count=%d", ids.length); + return R.success({ message: `已删除 ${ids.length} 个用户` }); + } + + const newStatus = body.action === "enable" ? "active" : "disabled"; + await dbGlobal + .update(users) + .set({ status: newStatus }) + .where(inArray(users.id, ids)); + + logger.info("users batch %s by admin: ids=%s", body.action, ids.join(",")); + return R.success({ + message: `已${body.action === "enable" ? "启用" : "禁用"} ${ids.length} 个用户` + }); +}); +``` + +- [ ] **Step 2: Commit** + +```bash +git add server/api/users/batch.post.ts +git commit -m "feat(users): add POST /api/users/batch for batch operations" +``` + +--- + +## Task 4: Rewrite users/index.vue page + +**Files:** +- Modify: `app/pages/admin/users/index.vue` + +This is the main frontend task. Follow the spec exactly: +- Filter bar with status tabs, role dropdown, search +- Stats cards (total, admin, disabled) +- Sortable table with checkboxes +- Batch action bar +- Detail drawer +- Create modal +- Confirm dialog + +- [ ] **Step 1: Read current implementation** + +Already read (320 lines) — use as reference for structure + +- [ ] **Step 2: Write new implementation** + +Complete rewrite with all features from spec: +- State tabs (全部/正常/禁用) using pill-style buttons +- Role dropdown select +- Search input with icon +- Sortable table columns (username, createdAt) +- Checkbox multi-select with select-all +- Stats cards row +- Batch bar slides in when selection > 0 +- Detail drawer from right side (480px wide) +- Create modal centered +- Confirm dialog for batch delete + +Use DESIGN.md tokens: +- `var(--color-primary)` for coral buttons +- `var(--color-accent-teal)` for enable actions +- `var(--color-error)` for delete actions +- `var(--color-surface-card)` for cards +- `var(--color-hairline)` for borders +- `var(--rounded-md)` (8px) for buttons/inputs +- `var(--rounded-lg)` (12px) for cards + +- [ ] **Step 3: Test the page** + +Start dev server `npm run dev` and verify: +- [ ] Filter by status works +- [ ] Filter by role works +- [ ] Search works +- [ ] Table sorting works (click username/createdAt headers) +- [ ] Checkbox selection works +- [ ] Batch bar appears when items selected +- [ ] Batch enable/disable works +- [ ] Batch delete shows confirm dialog +- [ ] Create modal works +- [ ] Edit drawer opens and saves + +- [ ] **Step 4: Commit** + +```bash +git add app/pages/admin/users/index.vue +git commit -m "feat(users): complete rewrite with filtering, sorting, batch operations" +``` + +--- + +## Verification Checklist + +- [ ] GET /api/users supports `status`, `role`, `sortBy`, `sortOrder` params +- [ ] PUT /api/users/:id updates nickname, email, role, status +- [ ] POST /api/users/batch handles enable/disable/delete +- [ ] Page shows filter bar with status tabs + role dropdown + search +- [ ] Page shows stats cards (total/admin/disabled) +- [ ] Table columns are sortable (username, createdAt) +- [ ] Checkbox multi-select works with select-all +- [ ] Batch action bar shows enable/disable/delete buttons +- [ ] Detail drawer opens on edit and saves changes +- [ ] Create modal adds new user +- [ ] Batch delete shows confirm dialog \ No newline at end of file diff --git a/docs/superpowers/specs/2026-05-27-users-management-design.md b/docs/superpowers/specs/2026-05-27-users-management-design.md new file mode 100644 index 0000000..06e23e5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-users-management-design.md @@ -0,0 +1,105 @@ +# 用户管理模块重新设计 + +## Overview + +重新实现 `app/pages/admin/users/` 模块,面向日常运维场景,提供功能完整、操作高效的用户管理界面。 + +## Layout Structure + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Header: 标题 "用户管理" + 统计卡片(总数/管理员/禁用) │ +├─────────────────────────────────────────────────────────────┤ +│ Filter Bar: [状态标签] [角色下拉] [搜索框] [+新增用户] │ +├─────────────────────────────────────────────────────────────┤ +│ Table: ☑ | 用户 | 邮箱 | 角色 | 状态 | 注册时间 | 操作 │ +│ ☐ 张三 xxx@ 管理员 正常 2024-01 编辑 │ +│ ☐ 李四 --- 普通用户 禁用 2024-02 编辑 │ +├─────────────────────────────────────────────────────────────┤ +│ Batch Bar (选中后显示): 已选中 N 项 [启用] [禁用] [删除] │ +├─────────────────────────────────────────────────────────────┤ +│ Pagination: 共 X 条,第 1/5 页 [上一页] [下一页] │ +└─────────────────────────────────────────────────────────────┘ + +Drawer: 点击编辑从右侧滑入,宽度 480px +``` + +## Components + +### Filter Bar + +- **状态标签筛选**:全部 / 正常 / 禁用,点击切换,pill 样式 +- **角色下拉**:全部角色 / 管理员 / 普通用户 +- **搜索框**:placeholder "搜索用户名、邮箱",回车触发搜索,搜索图标在左侧 +- **新增用户按钮**:右上角,primary 样式,图标 + 文字 + +### Stats Cards + +- 三列 grid 布局 +- 卡片:总用户数、管理员数、已禁用数 +- 数字使用 display 字体(28px),标签使用 caption(13px) + +### Data Table + +- **列头**:全选checkbox、用户、邮箱、角色、状态、注册时间、操作 +- **用户名列**:头像(36px圆形) + 昵称 + @username +- **角色/状态**:badge 样式,admin 用 coral 背景,user 用 teal 背景 +- **可排序列头**:用户名、注册时间,点击切换升序/降序,排序图标 +- **操作列**:「编辑」文字按钮 + +### Batch Action Bar + +- 选中 > 0 时从表格下方滑出 +- 显示 "已选中 N 项" +- 三个按钮:启用(teal)/ 禁用(muted)/ 删除(error) +- 删除按钮点击后弹出确认 Dialog,内容 "确定删除选中的 N 个用户?此操作不可撤销。" + +### Detail Drawer + +- 从右侧滑入,宽度 480px,overlay 背景 +- 标题:"编辑用户" + 用户名 +- 字段:用户名(只读)、昵称、邮箱、角色(select)、状态(select) +- 底部:取消(secondary)+ 保存(primary)按钮 + +### Create Modal + +- 居中弹窗,最大宽度 440px +- 标题:新增用户 +- 字段:用户名、密码、邮箱(选填)、角色(select) +- 底部:取消 + 创建按钮 + +### Confirm Dialog + +- 居中弹窗,最大宽度 400px +- 标题:确认删除 +- 内容:确定删除选中的 N 个用户?此操作不可撤销。 +- 底部:取消 + 确认删除(error 样式) + +## States + +### Table States + +- **Loading**:表格区域显示 "加载中..." +- **Empty**:无数据时显示 "暂无用户" +- **Error**:错误时显示错误信息,支持重试 + +### Empty States + +- 无筛选结果时显示 "未找到匹配的用户" + +## API Integration + +- `GET /api/users` — 列表查询,支持 `page`, `pageSize`, `search`, `status`, `role`, `sortBy`, `sortOrder` +- `POST /api/users` — 创建用户 +- `PUT /api/users/:id` — 更新用户 +- `DELETE /api/users/:id` — 删除用户 +- `PUT /api/users/batch` — 批量操作,支持 `action: 'enable' | 'disable' | 'delete'`, `ids: number[]` + +## Design Tokens + +遵循 DESIGN.md 系统: + +- 颜色:`primary` (#cc785c), `accent-teal` (#5db8a6), `error` (#c64545) +- 字体:display 用于数字,body 用于正文 +- 圆角:md (8px) 用于按钮/输入,lg (12px) 用于卡片 +- 间距:16px (md), 24px (lg), 32px (xl) \ No newline at end of file diff --git a/server/api/users/[id].put.ts b/server/api/users/[id].put.ts new file mode 100644 index 0000000..a519695 --- /dev/null +++ b/server/api/users/[id].put.ts @@ -0,0 +1,96 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } from "drizzle-pkg/lib/schema/auth"; +import { eq } from "drizzle-orm"; +import log4js from "logger"; +import { requireAdmin } from "#server/utils/admin-guard"; + +const logger = log4js.getLogger("USERS"); + +export default defineWrappedResponseHandler(async (event) => { + const id = Number(event.context.params?.id); + const body = await readBody(event); + requireAdmin(event); + + if (!id || isNaN(id)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的用户ID", + }); + } + + // Validate email format if provided + if (body.email !== undefined && body.email !== "" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { + throw createError({ + statusCode: 400, + statusMessage: "邮箱格式不正确", + }); + } + + // Validate role + if (body.role && !["admin", "user"].includes(body.role)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的角色", + }); + } + + // Validate status + if (body.status && !["active", "disabled"].includes(body.status)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的状态", + }); + } + + // Check user exists + const [existing] = await dbGlobal + .select({ id: users.id }) + .from(users) + .where(eq(users.id, id)); + + if (!existing) { + throw createError({ + statusCode: 404, + statusMessage: "用户不存在", + }); + } + + // Build update data + const updateData: Partial<{ + nickname: string | null; + email: string | null; + role: "admin" | "user"; + status: "active" | "disabled"; + }> = {}; + + if (body.nickname !== undefined) updateData.nickname = body.nickname || null; + if (body.email !== undefined) updateData.email = body.email || null; + if (body.role !== undefined) updateData.role = body.role; + if (body.status !== undefined) updateData.status = body.status; + + // Perform update + const [updated] = await dbGlobal + .update(users) + .set(updateData) + .where(eq(users.id, id)) + .returning({ + id: users.id, + username: users.username, + email: users.email, + nickname: users.nickname, + avatar: users.avatar, + role: users.role, + status: users.status, + createdAt: users.createdAt, + }); + + if (!updated) { + throw createError({ + statusCode: 404, + statusMessage: "用户更新失败", + }); + } + + logger.info("user updated by admin: %s (id: %d)", updated.username, id); + return R.success(updated); +}); \ No newline at end of file diff --git a/server/api/users/batch.post.ts b/server/api/users/batch.post.ts new file mode 100644 index 0000000..48e6fdb --- /dev/null +++ b/server/api/users/batch.post.ts @@ -0,0 +1,52 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { users } from "drizzle-pkg/lib/schema/auth"; +import { inArray } from "drizzle-orm"; +import log4js from "logger"; +import { requireAdmin } from "#server/utils/admin-guard"; + +const logger = log4js.getLogger("USERS"); + +export default defineWrappedResponseHandler(async (event) => { + requireAdmin(event); + + const body = await readBody(event); + + if (!body?.ids || !Array.isArray(body.ids) || body.ids.length === 0) { + throw createError({ + statusCode: 400, + statusMessage: "请选择要操作的用户", + }); + } + + if (!body?.action || !["enable", "disable", "delete"].includes(body.action)) { + throw createError({ + statusCode: 400, + statusMessage: "无效的操作类型", + }); + } + + const ids = body.ids.map((n: number) => Number(n)).filter(n => !isNaN(n)); + if (ids.length === 0) { + throw createError({ + statusCode: 400, + statusMessage: "无效的用户ID列表", + }); + } + + if (body.action === "delete") { + await dbGlobal.delete(users).where(inArray(users.id, ids)); + logger.info("users batch deleted by admin: count=%d", ids.length); + return R.success({ message: `已删除 ${ids.length} 个用户` }); + } + + const newStatus = body.action === "enable" ? "active" : "disabled"; + await dbGlobal + .update(users) + .set({ status: newStatus }) + .where(inArray(users.id, ids)); + + logger.info("users batch %s by admin: ids=%s", body.action, ids.join(",")); + return R.success({ + message: `已${body.action === "enable" ? "启用" : "禁用"} ${ids.length} 个用户` + }); +}); \ No newline at end of file diff --git a/server/api/users/index.get.ts b/server/api/users/index.get.ts index 063af6c..8860ac8 100644 --- a/server/api/users/index.get.ts +++ b/server/api/users/index.get.ts @@ -1,19 +1,51 @@ import { dbGlobal } from "drizzle-pkg/lib/db"; import { users } from "drizzle-pkg/lib/schema/auth"; -import { count, desc, like, or } from "drizzle-orm"; +import { and, count, desc, eq, like, or, asc } 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 status = query.status as string | undefined; + const role = query.role as string | undefined; + const sortBy = query.sortBy as string | undefined; + const sortOrder = query.sortOrder as string | undefined; const offset = (page - 1) * pageSize; const searchPattern = search ? `%${search}%` : undefined; + // Build conditions array + const conditions: any[] = []; + + if (searchPattern) { + conditions.push( + or( + like(users.username, searchPattern), + like(users.email, searchPattern), + like(users.nickname, searchPattern), + ) + ); + } + + if (status) { + conditions.push(eq(users.status, status)); + } + + if (role) { + conditions.push(eq(users.role, role)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + // Build orderBy + const orderColumn = sortBy === "username" ? users.username : users.createdAt; + const orderDirection = sortOrder === "asc" ? asc(orderColumn) : desc(orderColumn); + const [totalResult] = await dbGlobal .select({ total: count() }) - .from(users); + .from(users) + .where(whereClause); const list = await dbGlobal .select({ @@ -27,16 +59,8 @@ export default defineWrappedResponseHandler(async (event) => { 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)) + .where(whereClause) + .orderBy(orderDirection) .limit(pageSize) .offset(offset);