优化用户管理:支持编辑、检索与分页

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-05-01 14:15:17 +08:00
parent 00393052d6
commit 06bcc67b8e
6 changed files with 332 additions and 31 deletions
+20
View File
@@ -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`,且做唯一性校验;
- 空字符串视为非法更新(返回失败)。
+4 -2
View File
@@ -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
+1
View File
@@ -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
+53 -11
View File
@@ -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:
if payload.email is not None:
next_email = payload.email.strip().lower()
if not next_email:
return None
if next_email != user.email:
duplicate = db.scalar(
select(User.id).where(User.username == payload.username, User.id != user.id)
select(User.id).where(User.email == next_email, User.id != user.id)
)
if duplicate:
return None
user.username = payload.username
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:
+40
View File
@@ -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”,后续如需更精确错误码可再拆分。
+210 -14
View File
@@ -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<CreateUserValues>();
const [editUserForm] = Form.useForm<EditUserValues>();
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const [savingUserId, setSavingUserId] = useState<string | null>(null);
@@ -62,7 +70,12 @@ export default function AdminUsersPage() {
const [resettingUserId, setResettingUserId] = useState<string | null>(null);
const [updatingStatusUserId, setUpdatingStatusUserId] = useState<string | null>(null);
const [createUserModalOpen, setCreateUserModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserPublic | null>(null);
const [resetPasswordTarget, setResetPasswordTarget] = useState<UserPublic | null>(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<UserPublic>;
@@ -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 (
<Space wrap>
<Button
size="small"
loading={statusLoading}
loading={updatingLoading}
disabled={rowBusy || row.id === user?.id}
onClick={() => {
if (row.id === user?.id) {
@@ -419,12 +516,20 @@ export default function AdminUsersPage() {
return;
}
const nextStatus: "active" | "disabled" = row.status === "active" ? "disabled" : "active";
updateUserProfileMutation.mutate({ userId: row.id, status: nextStatus });
updateUserProfileMutation.mutate({ userId: row.id, payload: { status: nextStatus } });
}}
>
{row.status === "active" ? "禁用" : "启用"}
</Button>
<Button
size="small"
disabled={rowBusy}
onClick={() => openEditUserModal(row)}
>
</Button>
<Button
size="small"
loading={resetLoading}
@@ -490,6 +595,36 @@ export default function AdminUsersPage() {
{anyError && <Alert type="error" message="操作失败" description={anyError} showIcon />}
{success && <Alert type="success" message={success} showIcon />}
<AntCard
title="用户检索"
>
<Space wrap>
<Input
allowClear
placeholder="按用户ID/邮箱/用户名搜索"
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onPressEnter={handleSearch}
style={{ width: 320 }}
/>
<Select<"all" | "active" | "disabled">
value={statusFilter}
style={{ width: 160 }}
options={[
{ label: "全部状态", value: "all" },
{ label: "启用", value: "active" },
{ label: "禁用", value: "disabled" },
]}
onChange={(value) => {
setStatusFilter(value);
setPagination((prev) => ({ ...prev, current: 1 }));
}}
/>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleResetSearch}></Button>
</Space>
</AntCard>
<AntCard
title="用户列表"
extra={(
@@ -506,7 +641,16 @@ export default function AdminUsersPage() {
rowKey="id"
dataSource={users}
columns={columns}
pagination={false}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: usersQuery.data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) => {
setPagination({ current: page, pageSize });
},
}}
size="middle"
scroll={{ x: 1500 }}
locale={{
@@ -587,6 +731,58 @@ export default function AdminUsersPage() {
</Form>
</Modal>
<Modal
title={editingUser ? `编辑用户:${editingUser.username}${editingUser.id}` : "编辑用户"}
open={!!editingUser}
destroyOnClose
onCancel={closeEditUserModal}
onOk={() => editUserForm.submit()}
okText="保存"
cancelText="取消"
confirmLoading={updateUserProfileMutation.isPending}
>
<Form<EditUserValues>
form={editUserForm}
layout="vertical"
onFinish={handleSubmitEditUser}
autoComplete="off"
>
<Form.Item
label="邮箱"
name="email"
rules={[
{ required: true, message: "请输入邮箱" },
{ type: "email", message: "邮箱格式不正确" },
]}
>
<Input placeholder="请输入邮箱" />
</Form.Item>
<Form.Item
label="用户名"
name="username"
rules={[
{ required: true, message: "请输入用户名" },
{ min: 3, message: "用户名至少 3 位" },
{ max: 64, message: "用户名不能超过 64 位" },
]}
>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item
label="状态"
name="status"
rules={[{ required: true, message: "请选择状态" }]}
>
<Select
options={[
{ label: "启用", value: "active" },
{ label: "禁用", value: "disabled" },
]}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={resetPasswordTarget ? `重置密码:${resetPasswordTarget.username}${resetPasswordTarget.id}` : "重置密码"}
open={!!resetPasswordTarget}