diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py index fe282a6..dc9c27f 100644 --- a/api/app/api/v1/users.py +++ b/api/app/api/v1/users.py @@ -11,6 +11,7 @@ from ...schemas.user import ( UserPublic, UserRoleUpdateRequest, UserUpdateRequest, + UserIdCheckResponse, ) from ...services.user_service import ( UserCreateError, @@ -29,6 +30,24 @@ from ...services.user_service import ( router = APIRouter(prefix="/users", tags=["users"]) +@router.get("/check-id/{user_id}", response_model=UserIdCheckResponse) +def check_user_id_availability( + user_id: str, + exclude_user_id: str | None = Query(default=None), + _: CurrentUser = Depends(require_permission("user.manage")), + db: Session = Depends(get_db), +) -> UserIdCheckResponse: + """Check if a user ID is available. Use exclude_user_id when editing an existing user.""" + existing_user = get_user_by_id(db, user_id) + + if existing_user: + if exclude_user_id and existing_user.id.lower() == exclude_user_id.lower(): + return UserIdCheckResponse(available=True, message="Current user ID") + return UserIdCheckResponse(available=False, message="User ID already exists") + + return UserIdCheckResponse(available=True, message="User ID is available") + + @router.post("", response_model=UserPublic) def create_user_account( payload: UserCreateRequest, diff --git a/api/app/schemas/user.py b/api/app/schemas/user.py index cbf5fb3..da56e26 100644 --- a/api/app/schemas/user.py +++ b/api/app/schemas/user.py @@ -20,6 +20,11 @@ class UserListResponse(BaseModel): total: int +class UserIdCheckResponse(BaseModel): + available: bool + message: str + + class UserUpdateRequest(BaseModel): email: str | None = None username: str | None = Field(default=None, min_length=3, max_length=64) diff --git a/web/src/app/admin/users/page.tsx b/web/src/app/admin/users/page.tsx index bb47a67..7523e6a 100644 --- a/web/src/app/admin/users/page.tsx +++ b/web/src/app/admin/users/page.tsx @@ -50,6 +50,7 @@ type EditUserValues = { user_id: string; username: string; email: string; + status: "active" | "disabled"; }; type ResetPasswordValues = { @@ -96,6 +97,8 @@ export default function AdminUsersPage() { const [success, setSuccess] = useState(""); const [userIdValidationError, setUserIdValidationError] = useState(""); const [editUserIdValidationError, setEditUserIdValidationError] = useState(""); + const [checkingUserId, setCheckingUserId] = useState(false); + const userIdCheckTimeoutRef = useRef(null); const canManage = hasPermission("user.manage"); const canReadRoles = hasPermission("role.read") || hasPermission("role.manage"); @@ -347,7 +350,27 @@ export default function AdminUsersPage() { return null; }; - const handleUserIdChange = (value: string, isEdit: boolean = false) => { + const checkUserIdAvailability = async (userId: string, excludeUserId?: string) => { + try { + const params = new URLSearchParams({ user_id: userId }); + if (excludeUserId) { + params.set("exclude_user_id", excludeUserId); + } + const response = await fetchWithAuth(`/api/v1/users/check-id/${encodeURIComponent(userId)}?${excludeUserId ? `exclude_user_id=${encodeURIComponent(excludeUserId)}` : ""}`); + if (!response.ok) { + return { available: false, message: "检查失败" }; + } + return (await response.json()) as { available: boolean; message: string }; + } catch { + return { available: false, message: "检查失败" }; + } + }; + + const handleUserIdChange = async (value: string, isEdit: boolean = false) => { + if (userIdCheckTimeoutRef.current) { + clearTimeout(userIdCheckTimeoutRef.current); + } + const formatError = validateUserIdFormat(value); if (formatError) { @@ -359,7 +382,7 @@ export default function AdminUsersPage() { return; } - const trimmedValue = value.trim().toLowerCase(); + const trimmedValue = value.trim(); if (!trimmedValue) { if (isEdit) { setEditUserIdValidationError(""); @@ -369,22 +392,25 @@ export default function AdminUsersPage() { return; } - if (isEdit && editingUser) { - if (trimmedValue !== editingUser.id.toLowerCase() && existingUserIds.has(trimmedValue)) { - setEditUserIdValidationError("用户 ID 已存在,请更换后重试"); + setCheckingUserId(true); + + userIdCheckTimeoutRef.current = setTimeout(async () => { + const result = await checkUserIdAvailability( + trimmedValue, + isEdit && editingUser ? editingUser.id : undefined + ); + + setCheckingUserId(false); + + if (isEdit) { + setEditUserIdValidationError(result.available ? "" : result.message); } else { - setEditUserIdValidationError(""); + setUserIdValidationError(result.available ? "" : result.message); } - } else { - if (existingUserIds.has(trimmedValue)) { - setUserIdValidationError("用户 ID 已存在,请更换后重试"); - } else { - setUserIdValidationError(""); - } - } + }, 500); }; - const handleCreateUser = (values: CreateUserValues) => { + const handleCreateUser = async (values: CreateUserValues) => { setError(""); setSuccess(""); @@ -404,14 +430,15 @@ export default function AdminUsersPage() { return; } - const candidateUserId = payload.user_id.toLowerCase(); + const availabilityCheck = await checkUserIdAvailability(payload.user_id); + if (!availabilityCheck.available) { + setError(availabilityCheck.message); + return; + } + const candidateEmail = payload.email?.toLowerCase(); const candidateUsername = payload.username.toLowerCase(); - if (existingUserIds.has(candidateUserId)) { - setError("用户 ID 已存在,请更换后重试"); - return; - } if (candidateEmail && existingEmails.has(candidateEmail)) { setError("邮箱已存在,请更换后重试"); return; @@ -477,6 +504,7 @@ export default function AdminUsersPage() { user_id: target.id, username: target.username, email: target.email, + status: target.status === "disabled" ? "disabled" : "active", }); }; @@ -487,11 +515,12 @@ export default function AdminUsersPage() { editUserForm.resetFields(); }; - const handleSubmitEditUser = (values: EditUserValues) => { + const handleSubmitEditUser = async (values: EditUserValues) => { if (!editingUser) return; const nextUserId = values.user_id.trim(); const nextUsername = values.username.trim(); const nextEmail = values.email ? values.email.trim().toLowerCase() : ""; + const nextStatus = values.status; const formatError = validateUserIdFormat(nextUserId); if (formatError) { @@ -502,9 +531,9 @@ export default function AdminUsersPage() { const payload: { new_user_id?: string; username?: string; email?: string; status?: "active" | "disabled" } = {}; if (nextUserId !== editingUser.id) { - const lowerUserId = nextUserId.toLowerCase(); - if (existingUserIds.has(lowerUserId) && lowerUserId !== editingUser.id.toLowerCase()) { - setError("用户 ID 已存在,请更换后重试"); + const availabilityCheck = await checkUserIdAvailability(nextUserId, editingUser.id); + if (!availabilityCheck.available) { + setError(availabilityCheck.message); return; } payload.new_user_id = nextUserId; @@ -515,6 +544,9 @@ export default function AdminUsersPage() { if (nextEmail && nextEmail !== editingUser.email.toLowerCase()) { payload.email = nextEmail; } + if (nextStatus !== editingUser.status) { + payload.status = nextStatus; + } if (Object.keys(payload).length === 0) { setSuccess("未检测到变更"); @@ -598,6 +630,14 @@ export default function AdminUsersPage() { window.requestAnimationFrame(updateTableScrollY); }, [anyError, paginationCurrent, paginationPageSize, users.length, usersQuery.isFetching, updateTableScrollY]); + useEffect(() => { + return () => { + if (userIdCheckTimeoutRef.current) { + clearTimeout(userIdCheckTimeoutRef.current); + } + }; + }, []); + useEffect(() => { if (typeof window === "undefined") { return;