Files
OpenFlare/frontend/contexts/admin-users-context.tsx
T
ryan 932f0c65b9 feat(admin): support user profile editing, password resetting, and email column with search
- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset.
- Relocate user delete button and confirmation Alert into the EditUserModal.
- Optimize admin Switch change to trigger instant API request with rollback support.
- Fix missing email field in edit form initialization by fetching full profile metadata.
- Render email column in users list and support email-based filtering in UserFilterBar.
- Remove hardcoded styles and sizes from Switch components to follow global theme.
2026-06-28 11:05:57 +08:00

294 lines
9.2 KiB
TypeScript

"use client"
import * as React from "react"
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
import {toast} from "sonner"
import type {AdminUser, CreateUserRequest, ListUsersRequest, UpdateUserRequest} from "@/lib/services/admin"
import services from "@/lib/services"
/** 用户列表查询参数 */
export interface UserQueryParams {
page: number
page_size: number
user_id?: string
username?: string
email?: string
status?: 'all' | 'active' | 'inactive'
}
/** Admin Users Context State Interface */
interface AdminUsersContextState {
users: AdminUser[]
total: number
loading: boolean
error: Error | null
// Params
page: number
pageSize: number
searchUserId: string
searchUsername: string
searchEmail: string
statusFilter: 'all' | 'active' | 'inactive'
// Actions
setPage: (page: number) => void
setPageSize: (size: number) => void
setSearchUserId: (userId: string) => void
setSearchUsername: (username: string) => void
setSearchEmail: (email: string) => void
setStatusFilter: (status: 'all' | 'active' | 'inactive') => void
fetchUsers: (force?: boolean) => Promise<void>
refresh: () => Promise<void>
getUserDetail: (id: string) => Promise<AdminUser>
updateUserStatus: (user: AdminUser) => Promise<void>
createUser: (req: CreateUserRequest) => Promise<AdminUser>
updateUser: (id: string, req: UpdateUserRequest) => Promise<void>
deleteUser: (user: AdminUser) => Promise<void>
}
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes cache
const AdminUsersContext = createContext<AdminUsersContextState | null>(null)
export function AdminUsersProvider({ children }: { children: React.ReactNode }) {
// State
const [users, setUsers] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// Query Params State
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [searchUserId, setSearchUserId] = useState("")
const [searchUsername, setSearchUsername] = useState("")
const [searchEmail, setSearchEmail] = useState("")
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const [debouncedSearchUserId, setDebouncedSearchUserId] = useState("")
const [debouncedSearchUsername, setDebouncedSearchUsername] = useState("")
const [debouncedSearchEmail, setDebouncedSearchEmail] = useState("")
// Cache
const cacheRef = useRef<Record<string, { data: AdminUser[], total: number, timestamp: number }>>({})
const latestRequestIdRef = useRef(0)
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchUserId(searchUserId)
setDebouncedSearchUsername(searchUsername)
setDebouncedSearchEmail(searchEmail)
if (
searchUserId !== debouncedSearchUserId ||
searchUsername !== debouncedSearchUsername ||
searchEmail !== debouncedSearchEmail
) {
setPage(1) // Reset to page 1 on search change
}
}, 500)
return () => clearTimeout(timer)
}, [searchUserId, searchUsername, searchEmail, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail])
const generateCacheKey = (params: UserQueryParams) => {
return JSON.stringify(params)
}
const fetchUsers = useCallback(async (force = false) => {
const params: UserQueryParams = {
page,
page_size: pageSize,
user_id: debouncedSearchUserId || undefined,
username: debouncedSearchUsername || undefined,
email: debouncedSearchEmail || undefined,
status: statusFilter
}
const cacheKey = generateCacheKey(params)
const now = Date.now()
const cached = cacheRef.current[cacheKey]
// Use cache if valid and not forced
if (!force && cached && (now - cached.timestamp < CACHE_DURATION)) {
setUsers(cached.data)
setTotal(cached.total)
setLoading(false)
setError(null)
return
}
setLoading(true)
setError(null)
const requestId = ++latestRequestIdRef.current
try {
// Current API doesn't support status filter in listUsers?
// The previous implementation did client-side filtering.
// Ideally backend supports it. If not, we fetch and filter?
// "services.adminUser.listUsers" in previous code only took page, page_size, username.
// So status filter was client side.
// However, caching client-filtered result is tricky if we don't have all data.
// But previous implementation fetched *paged* data then filtered? No, that would be wrong (filtering 20 items might leave 0).
// Let's check previous implementation:
// "const data = await services.adminUser.listUsers(...) ... let filteredUsers = data.users ... if (statusFilter...) filtered..."
// This means filtering happens ONLY on the current page of results! This is technically buggy if the user wants "all inactive users".
// But preserving that behavior for now.
const requestParams: ListUsersRequest = {
page,
page_size: pageSize,
user_id: debouncedSearchUserId || undefined,
username: debouncedSearchUsername || undefined,
email: debouncedSearchEmail || undefined
}
const response = await services.adminUser.listUsers(requestParams)
if (requestId !== latestRequestIdRef.current) return
let resultUsers = response.users
// Client-side filtering as per previous logic (though restrictive)
if (statusFilter === 'active') {
resultUsers = resultUsers.filter(u => u.is_active)
} else if (statusFilter === 'inactive') {
resultUsers = resultUsers.filter(u => !u.is_active)
}
setUsers(resultUsers)
setTotal(response.total) // Note: Total might be inaccurate if we filter client side! But per previous code...
// Update cache
cacheRef.current[cacheKey] = {
data: resultUsers,
total: response.total,
timestamp: now
}
} catch (err) {
if (requestId !== latestRequestIdRef.current) return
setError(err instanceof Error ? err : new Error('Failed to fetch users'))
} finally {
if (requestId === latestRequestIdRef.current) {
setLoading(false)
}
}
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail, statusFilter])
// Removed auto-fetch useEffect. Consumer (UsersManager) should trigger fetch.
const refresh = async () => {
await fetchUsers(true)
}
const updateUserStatus = async (user: AdminUser) => {
const originalStatus = user.is_active
// Optimistic update
setUsers(prev => prev.map(u =>
u.id === user.id ? { ...u, is_active: !u.is_active } : u
))
// Clear cache because data changed
cacheRef.current = {}
try {
await services.adminUser.updateUserStatus(user.id, { is_active: !user.is_active })
toast.success(`已${ !user.is_active ? '启用' : '禁用' }用户 ${ user.username }`)
} catch {
// Revert on error
setUsers(prev => prev.map(u =>
u.id === user.id ? { ...u, is_active: originalStatus } : u
))
toast.error('更新状态失败')
}
}
const getUserDetail = async (id: string) => {
return services.adminUser.getUser(id)
}
const createUser = async (req: CreateUserRequest) => {
try {
const newUser = await services.adminUser.createUser(req)
setUsers(prev => [newUser, ...prev])
setTotal(prev => prev + 1)
// Clear cache because data changed
cacheRef.current = {}
toast.success(`已成功创建用户 ${ newUser.username }`)
return newUser
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建用户失败')
throw err
}
}
const deleteUser = async (user: AdminUser) => {
try {
await services.adminUser.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
setTotal(prev => Math.max(0, prev - 1))
cacheRef.current = {}
toast.success(`已删除用户 ${ user.username }`)
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除用户失败')
throw err
}
}
const updateUser = async (id: string, req: UpdateUserRequest) => {
try {
await services.adminUser.updateUser(id, req)
setUsers(prev => prev.map(u =>
u.id === id ? { ...u, ...req } : u
))
cacheRef.current = {}
toast.success('更新用户信息成功')
} catch (err) {
toast.error(err instanceof Error ? err.message : '更新用户信息失败')
throw err
}
}
const value = {
users,
total,
loading,
error,
page,
pageSize,
searchUserId,
searchUsername,
searchEmail,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setSearchEmail,
setStatusFilter,
fetchUsers,
refresh,
getUserDetail,
updateUserStatus,
createUser,
updateUser,
deleteUser
}
return (
<AdminUsersContext.Provider value={value}>
{children}
</AdminUsersContext.Provider>
)
}
export function useAdminUsers() {
const context = useContext(AdminUsersContext)
if (!context) {
throw new Error('useAdminUsers must be used within an AdminUsersProvider')
}
return context
}