From 06bcc67b8e920330168290df9cd33bd37b16fa40 Mon Sep 17 00:00:00 2001 From: chengkml Date: Fri, 1 May 2026 14:15:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=EF=BC=9A=E6=94=AF=E6=8C=81=E7=BC=96=E8=BE=91=E3=80=81?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E4=B8=8E=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- MEMORY.md | 20 +++ api/app/api/v1/users.py | 6 +- api/app/schemas/user.py | 1 + api/app/services/user_service.py | 72 +++++++--- memory/2026-05-01.md | 40 ++++++ web/src/app/admin/users/page.tsx | 224 +++++++++++++++++++++++++++++-- 6 files changed, 332 insertions(+), 31 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index b160bd3..d3692a6 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -972,3 +972,23 @@ - 当 `public.user_role` 存在:继续走 legacy 表链路。 - 当 `public.user_role` 缺失:自动回退到 modern 表链路,并补齐 `role_permissions + permissions` 权限码。 - 该兼容仅针对角色读取相关接口(`list_roles/get_role_by_id/list_role_menu_ids`);角色写入链路暂仍以 legacy 表为准。 + +## 用户管理检索与分页口径(2026-05-01) + +- 用户管理列表接口 `GET /api/v1/users` 支持查询参数: + - `limit` / `offset`:分页 + - `keyword`:按 `user_id/email/username` 模糊检索 + - `status`:状态过滤(`active|enabled|disabled`) +- 后端统计口径要求:`total` 必须与当前检索/过滤条件一致(不是全量总数)。 +- 前端 `/admin/users` 采用“检索条件 + 分页状态”驱动请求,不再固定拉取 `limit=200` 全量列表。 + +## 用户管理编辑口径(2026-05-01) + +- 用户信息更新接口 `PATCH /api/v1/users/{user_id}` 当前支持: + - `email` + - `username` + - `status` +- 更新规则: + - `email` 入库前统一 `trim + lower`,且做唯一性校验; + - `username` 入库前统一 `trim`,且做唯一性校验; + - 空字符串视为非法更新(返回失败)。 diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py index 5d37af4..da48e6a 100644 --- a/api/app/api/v1/users.py +++ b/api/app/api/v1/users.py @@ -45,10 +45,12 @@ def create_user_account( def list_all_users( limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), + keyword: str | None = Query(default=None, max_length=128), + status_filter: str | None = Query(default=None, alias="status"), _: CurrentUser = Depends(require_permission("user.manage")), db: Session = Depends(get_db), ) -> UserListResponse: - return list_users(db, limit=limit, offset=offset) + return list_users(db, limit=limit, offset=offset, keyword=keyword, status=status_filter) @router.get("/{user_id}", response_model=UserPublic) @@ -81,7 +83,7 @@ def update_user_profile( if not updated: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found or username exists", + detail="User not found or email/username exists", ) return updated diff --git a/api/app/schemas/user.py b/api/app/schemas/user.py index f04f790..a510442 100644 --- a/api/app/schemas/user.py +++ b/api/app/schemas/user.py @@ -21,6 +21,7 @@ class UserListResponse(BaseModel): class UserUpdateRequest(BaseModel): + email: str | None = None username: str | None = Field(default=None, min_length=3, max_length=64) status: Literal["active", "disabled", "enabled"] | None = None diff --git a/api/app/services/user_service.py b/api/app/services/user_service.py index d847a0b..cda83bd 100644 --- a/api/app/services/user_service.py +++ b/api/app/services/user_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from uuid import uuid4 -from sqlalchemy import and_, bindparam, func, select, text +from sqlalchemy import and_, bindparam, func, or_, select, text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, object_session @@ -33,14 +33,40 @@ def _user_with_rbac_stmt(): return select(User) -def list_users(db: Session, *, limit: int, offset: int) -> UserListResponse: - total = db.scalar(select(func.count()).select_from(User)) or 0 - stmt = ( - _user_with_rbac_stmt() - .order_by(User.created_at.desc()) - .offset(offset) - .limit(limit) - ) +def list_users( + db: Session, + *, + limit: int, + offset: int, + keyword: str | None = None, + status: str | None = None, +) -> UserListResponse: + conditions = [] + normalized_keyword = (keyword or "").strip() + if normalized_keyword: + like = f"%{normalized_keyword}%" + conditions.append( + or_( + User.id.ilike(like), + User.email.ilike(like), + User.username.ilike(like), + ) + ) + normalized_status = (status or "").strip().lower() + if normalized_status in {"active", "enabled"}: + conditions.append(User.status.in_(["active", "ACTIVE", "ENABLED"])) + elif normalized_status == "disabled": + conditions.append(User.status.in_(["disabled", "DISABLED", "INACTIVE"])) + + total_stmt = select(func.count()).select_from(User) + if conditions: + total_stmt = total_stmt.where(*conditions) + total = db.scalar(total_stmt) or 0 + + stmt = _user_with_rbac_stmt().order_by(User.created_at.desc()) + if conditions: + stmt = stmt.where(*conditions) + stmt = stmt.offset(offset).limit(limit) users = db.execute(stmt).unique().scalars().all() return UserListResponse(items=[serialize_user(user) for user in users], total=total) @@ -168,13 +194,29 @@ def update_user( if not user: return None - if payload.username and payload.username != user.username: - duplicate = db.scalar( - select(User.id).where(User.username == payload.username, User.id != user.id) - ) - if duplicate: + if payload.email is not None: + next_email = payload.email.strip().lower() + if not next_email: return None - user.username = payload.username + if next_email != user.email: + duplicate = db.scalar( + select(User.id).where(User.email == next_email, User.id != user.id) + ) + if duplicate: + return None + user.email = next_email + + if payload.username is not None: + next_username = payload.username.strip() + if not next_username: + return None + if next_username != user.username: + duplicate = db.scalar( + select(User.id).where(User.username == next_username, User.id != user.id) + ) + if duplicate: + return None + user.username = next_username status_changed = False if payload.status: diff --git a/memory/2026-05-01.md b/memory/2026-05-01.md index 70d3491..3efbe31 100644 --- a/memory/2026-05-01.md +++ b/memory/2026-05-01.md @@ -206,3 +206,43 @@ - 风险与影响: - 影响范围限定在后台角色读取接口(`/api/v1/admin/roles*`)查询链路。 - 对 legacy 表完整的数据库行为保持不变;仅在 `user_role` 缺失时触发 modern 回退逻辑。 + +## Work Log - 用户管理优化(编辑/检索/分页)(2026-05-01) + +- 背景: + - Issue `FL-121` 要求用户管理支持:用户信息修改、用户检索、用户表格分页。 + +- 本次改动(最小闭环): + - 后端 `users` 列表接口增强(检索 + 分页参数联动): + - 文件:`api/app/api/v1/users.py` + - `GET /api/v1/users` 新增查询参数: + - `keyword`:按 `user_id/email/username` 模糊检索 + - `status`:按启用/禁用状态过滤 + - 后端用户服务增强: + - 文件:`api/app/services/user_service.py` + - `list_users(...)` 支持 `keyword/status` 条件查询并对 `total` 同步计数。 + - `update_user(...)` 支持修改 `email` 与 `username`: + - 统一 trim/归一化 + - 重复值冲突校验 + - 空值保护 + - 用户更新请求模型补齐: + - 文件:`api/app/schemas/user.py` + - `UserUpdateRequest` 新增可选字段 `email`。 + - 前端用户管理页增强: + - 文件:`web/src/app/admin/users/page.tsx` + - 新增“用户检索”区:关键字输入 + 状态筛选 + 搜索/重置。 + - 用户列表请求改为受查询条件驱动(query key 包含分页/筛选参数)。 + - 表格接入分页器(页码、每页条数、总数联动后端)。 + - 新增“编辑用户”弹窗,支持修改邮箱/用户名/状态。 + - 编辑提交仅传变更字段,避免无效更新。 + +- 验证(未执行编译/构建,遵循当前任务约束): + - 代码走读与关键路径自检: + - `GET /api/v1/users` -> 支持 `limit/offset/keyword/status`。 + - `PATCH /api/v1/users/{id}` -> 支持 `email/username/status` 更新。 + - 前端列表查询参数与分页状态联动一致。 + +- 风险与影响: + - 影响范围集中在用户管理模块(`/admin/users` 与 `/api/v1/users*`)。 + - 旧调用方不传 `keyword/status` 时行为保持兼容。 + - 更新失败错误提示文案仍共用“not found or email/username exists”,后续如需更精确错误码可再拆分。 diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx index daf9b9f..d2d8db1 100644 --- a/web/src/app/admin/users/page.tsx +++ b/web/src/app/admin/users/page.tsx @@ -11,6 +11,7 @@ import { Input, Modal, Popconfirm, + Select, Space, Spin, Table, @@ -40,6 +41,12 @@ type CreateUserValues = { password: string; }; +type EditUserValues = { + email: string; + username: string; + status: "active" | "disabled"; +}; + type ResetPasswordValues = { password: string; }; @@ -55,6 +62,7 @@ export default function AdminUsersPage() { const queryClient = useQueryClient(); const [createForm] = Form.useForm(); + const [editUserForm] = Form.useForm(); const [resetPasswordForm] = Form.useForm(); const [savingUserId, setSavingUserId] = useState(null); @@ -62,7 +70,12 @@ export default function AdminUsersPage() { const [resettingUserId, setResettingUserId] = useState(null); const [updatingStatusUserId, setUpdatingStatusUserId] = useState(null); const [createUserModalOpen, setCreateUserModalOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); const [resetPasswordTarget, setResetPasswordTarget] = useState(null); + const [keywordInput, setKeywordInput] = useState(""); + const [searchKeyword, setSearchKeyword] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "disabled">("all"); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20 }); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); @@ -70,14 +83,27 @@ export default function AdminUsersPage() { const canManage = hasPermission("user.manage"); const canReadRoles = hasPermission("role.read") || hasPermission("role.manage"); - const usersPath = "/api/v1/users?limit=200&offset=0"; + const trimmedKeyword = searchKeyword.trim(); + const usersQueryParams = useMemo(() => { + const params = new URLSearchParams(); + params.set("limit", String(pagination.pageSize)); + params.set("offset", String((pagination.current - 1) * pagination.pageSize)); + if (trimmedKeyword) { + params.set("keyword", trimmedKeyword); + } + if (statusFilter !== "all") { + params.set("status", statusFilter); + } + return params.toString(); + }, [pagination.current, pagination.pageSize, statusFilter, trimmedKeyword]); + const usersPath = `/api/v1/users?${usersQueryParams}`; const rolesPath = "/api/v1/admin/roles"; const loadUsers = useCallback(async () => { const response = await fetchWithAuth(usersPath); if (!response.ok) throw new Error(await readApiError(response)); return (await response.json()) as UserListResponse; - }, [fetchWithAuth]); + }, [fetchWithAuth, usersPath]); const loadRoles = useCallback(async () => { const response = await fetchWithAuth(rolesPath); @@ -86,7 +112,7 @@ export default function AdminUsersPage() { }, [fetchWithAuth]); const usersQuery = useQuery({ - queryKey: [usersPath], + queryKey: ["admin.users", usersQueryParams], queryFn: loadUsers, enabled: !!user && canManage, }); @@ -101,7 +127,7 @@ export default function AdminUsersPage() { "admin.users", useCallback(() => { if (!user || !canManage) return; - void queryClient.invalidateQueries({ queryKey: [usersPath] }); + void queryClient.invalidateQueries({ queryKey: ["admin.users"] }); if (canReadRoles) { void queryClient.invalidateQueries({ queryKey: [rolesPath] }); } @@ -136,7 +162,7 @@ export default function AdminUsersPage() { ); const refreshData = async () => { - await queryClient.invalidateQueries({ queryKey: [usersPath] }); + await queryClient.invalidateQueries({ queryKey: ["admin.users"] }); if (canReadRoles) { await queryClient.invalidateQueries({ queryKey: [rolesPath] }); } @@ -218,11 +244,18 @@ export default function AdminUsersPage() { }); const updateUserProfileMutation = useMutation({ - mutationFn: async ({ userId, status }: { userId: string; status: "active" | "disabled" }) => { + mutationFn: async ({ userId, payload }: { + userId: string; + payload: { + email?: string; + username?: string; + status?: "active" | "disabled"; + }; + }) => { const response = await fetchWithAuth(`/api/v1/users/${userId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status }), + body: JSON.stringify(payload), }); if (!response.ok) throw new Error(await readApiError(response)); return response.json() as Promise; @@ -233,12 +266,18 @@ export default function AdminUsersPage() { setSuccess(""); }, onSuccess: async (_, variables) => { - setSuccess(variables.status === "active" ? "用户已启用" : "用户已禁用"); + if (variables.payload.status) { + setSuccess(variables.payload.status === "active" ? "用户已启用" : "用户已禁用"); + } else { + setSuccess("用户信息已更新"); + } + setEditingUser(null); + editUserForm.resetFields(); await refreshData(); }, onError: (candidate) => { setSuccess(""); - setError(candidate instanceof Error ? candidate.message : "更新用户状态失败"); + setError(candidate instanceof Error ? candidate.message : "更新用户信息失败"); }, onSettled: () => setUpdatingStatusUserId(null), }); @@ -309,6 +348,52 @@ export default function AdminUsersPage() { resetPasswordForm.resetFields(); }; + const openEditUserModal = (target: UserPublic) => { + setError(""); + setSuccess(""); + setEditingUser(target); + editUserForm.setFieldsValue({ + email: target.email, + username: target.username, + status: target.status === "disabled" ? "disabled" : "active", + }); + }; + + const closeEditUserModal = () => { + if (updateUserProfileMutation.isPending) return; + setEditingUser(null); + editUserForm.resetFields(); + }; + + const handleSubmitEditUser = (values: EditUserValues) => { + if (!editingUser) return; + const nextEmail = values.email.trim().toLowerCase(); + const nextUsername = values.username.trim(); + const nextStatus = values.status; + + const payload: { email?: string; username?: string; status?: "active" | "disabled" } = {}; + if (nextEmail !== editingUser.email.toLowerCase()) { + payload.email = nextEmail; + } + if (nextUsername !== editingUser.username) { + payload.username = nextUsername; + } + if (nextStatus !== editingUser.status) { + payload.status = nextStatus; + } + + if (Object.keys(payload).length === 0) { + setSuccess("未检测到变更"); + closeEditUserModal(); + return; + } + + updateUserProfileMutation.mutate({ + userId: editingUser.id, + payload, + }); + }; + const handleSubmitResetPassword = (values: ResetPasswordValues) => { if (!resetPasswordTarget) return; resetPasswordMutation.mutate({ userId: resetPasswordTarget.id, password: values.password }); @@ -327,6 +412,18 @@ export default function AdminUsersPage() { createForm.resetFields(); }; + const handleSearch = () => { + setSearchKeyword(keywordInput); + setPagination((prev) => ({ ...prev, current: 1 })); + }; + + const handleResetSearch = () => { + setKeywordInput(""); + setSearchKeyword(""); + setStatusFilter("all"); + setPagination((prev) => ({ ...prev, current: 1 })); + }; + const queryError = (usersQuery.error instanceof Error ? usersQuery.error.message : "") || (rolesQuery.error instanceof Error ? rolesQuery.error.message : ""); @@ -402,16 +499,16 @@ export default function AdminUsersPage() { fixed: "right", width: 260, render: (_value, row) => { - const statusLoading = updatingStatusUserId === row.id; + const updatingLoading = updatingStatusUserId === row.id; const resetLoading = resettingUserId === row.id; const deleteLoading = deletingUserId === row.id; - const rowBusy = statusLoading || resetLoading || deleteLoading; + const rowBusy = updatingLoading || resetLoading || deleteLoading; return ( + + + + + + `共 ${total} 条`, + onChange: (page, pageSize) => { + setPagination({ current: page, pageSize }); + }, + }} size="middle" scroll={{ x: 1500 }} locale={{ @@ -587,6 +731,58 @@ export default function AdminUsersPage() { + editUserForm.submit()} + okText="保存" + cancelText="取消" + confirmLoading={updateUserProfileMutation.isPending} + > + + form={editUserForm} + layout="vertical" + onFinish={handleSubmitEditUser} + autoComplete="off" + > + + + + + + + +