Browse Source

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 <noreply@anthropic.com>
as
npmrun 2 days ago
parent
commit
8eef6ea136
  1. 1040
      app/pages/admin/users/index.vue
  2. 368
      docs/superpowers/plans/2026-05-27-users-management-implementation.md
  3. 105
      docs/superpowers/specs/2026-05-27-users-management-design.md
  4. 96
      server/api/users/[id].put.ts
  5. 52
      server/api/users/batch.post.ts
  6. 48
      server/api/users/index.get.ts

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

File diff suppressed because it is too large

368
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

105
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)

96
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);
});

52
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} 个用户`
});
});

48
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);

Loading…
Cancel
Save