feat(users): add user device management and registration ID display
All checks were successful
Admin CI / build-and-push-web (push) Successful in 2m27s
All checks were successful
Admin CI / build-and-push-web (push) Successful in 2m27s
Add UserDevice interface and API endpoint for fetching user devices. Implement device dialog in the users page with copy-to-clipboard functionality for push tokens and registration IDs.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
export { default as apiClient } from './client'
|
export { default as apiClient } from './client'
|
||||||
export { authApi } from './auth'
|
export { authApi } from './auth'
|
||||||
export { usersApi } from './users'
|
export { usersApi } from './users'
|
||||||
export type { UserQueryParams, UserDetail } from './users'
|
export type { UserQueryParams, UserDetail, UserDevice } from './users'
|
||||||
export { rolesApi } from './roles'
|
export { rolesApi } from './roles'
|
||||||
export type { Permission, RoleInfo, RoleDetail } from './roles'
|
export type { Permission, RoleInfo, RoleDetail } from './roles'
|
||||||
export { postsApi } from './posts'
|
export { postsApi } from './posts'
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ export interface UserDetail extends User {
|
|||||||
following_count: number
|
following_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户设备(含 JPush RegistrationID)
|
||||||
|
export interface UserDevice {
|
||||||
|
id: number
|
||||||
|
user_id: string
|
||||||
|
device_id: string
|
||||||
|
device_type: string // ios | android | web
|
||||||
|
push_token?: string // JPush RegistrationID
|
||||||
|
is_active: boolean
|
||||||
|
device_name?: string
|
||||||
|
last_used_at?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
// 用户管理API
|
// 用户管理API
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
// 获取用户列表(分页、搜索、筛选)
|
// 获取用户列表(分页、搜索、筛选)
|
||||||
@@ -27,6 +41,10 @@ export const usersApi = {
|
|||||||
getUserById: (id: string) =>
|
getUserById: (id: string) =>
|
||||||
apiClient.get<ApiResponse<UserDetail>>(`/admin/users/${id}`),
|
apiClient.get<ApiResponse<UserDetail>>(`/admin/users/${id}`),
|
||||||
|
|
||||||
|
// 获取用户设备列表(含 registration_id / push_token)
|
||||||
|
getUserDevices: (id: string) =>
|
||||||
|
apiClient.get<ApiResponse<UserDevice[]>>(`/admin/users/${id}/devices`),
|
||||||
|
|
||||||
// 更新用户状态(封禁/解封)
|
// 更新用户状态(封禁/解封)
|
||||||
updateUserStatus: (id: string, status: 'active' | 'banned') =>
|
updateUserStatus: (id: string, status: 'active' | 'banned') =>
|
||||||
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),
|
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import type { User, Role, PaginatedResponse } from '@/types'
|
import type { User, Role, PaginatedResponse } from '@/types'
|
||||||
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType } from '@/api'
|
import { usersApi, type UserQueryParams, type UserDetail as UserDetailType, type UserDevice } from '@/api'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import { PaginationNavigator } from '@/components/ui/pagination'
|
import { PaginationNavigator } from '@/components/ui/pagination'
|
||||||
import UserDetail from '@/components/UserDetail'
|
import UserDetail from '@/components/UserDetail'
|
||||||
import UserRoleDialog from '@/components/UserRoleDialog'
|
import UserRoleDialog from '@/components/UserRoleDialog'
|
||||||
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Loader2 } from 'lucide-react'
|
import { Search, RefreshCw, Eye, UserPlus, Ban, Unlock, Smartphone, Loader2, Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
// 状态颜色映射
|
// 状态颜色映射
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
@@ -75,6 +75,13 @@ export default function Users() {
|
|||||||
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
|
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
|
||||||
const [allRoles, setAllRoles] = useState<Role[]>([])
|
const [allRoles, setAllRoles] = useState<Role[]>([])
|
||||||
|
|
||||||
|
// 设备对话框
|
||||||
|
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false)
|
||||||
|
const [deviceUser, setDeviceUser] = useState<User | null>(null)
|
||||||
|
const [devices, setDevices] = useState<UserDevice[]>([])
|
||||||
|
const [devicesLoading, setDevicesLoading] = useState(false)
|
||||||
|
const [copiedToken, setCopiedToken] = useState<string | null>(null)
|
||||||
|
|
||||||
// 加载用户列表
|
// 加载用户列表
|
||||||
const loadUsers = useCallback(async () => {
|
const loadUsers = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -149,6 +156,35 @@ export default function Users() {
|
|||||||
setRoleDialogOpen(true)
|
setRoleDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开设备对话框(查询 registration_id)
|
||||||
|
const handleOpenDeviceDialog = async (user: User) => {
|
||||||
|
setDeviceUser(user)
|
||||||
|
setDeviceDialogOpen(true)
|
||||||
|
setDevicesLoading(true)
|
||||||
|
setDevices([])
|
||||||
|
try {
|
||||||
|
const response = await usersApi.getUserDevices(user.id)
|
||||||
|
setDevices(response.data.data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user devices:', error)
|
||||||
|
setDevices([])
|
||||||
|
} finally {
|
||||||
|
setDevicesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制 registration_id
|
||||||
|
const handleCopyToken = async (token: string) => {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token)
|
||||||
|
setCopiedToken(token)
|
||||||
|
window.setTimeout(() => setCopiedToken((cur) => (cur === token ? null : cur)), 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分配角色
|
// 分配角色
|
||||||
const handleAssignRole = async (userId: string, role: string) => {
|
const handleAssignRole = async (userId: string, role: string) => {
|
||||||
await usersApi.assignRole(userId, role)
|
await usersApi.assignRole(userId, role)
|
||||||
@@ -365,6 +401,14 @@ export default function Users() {
|
|||||||
>
|
>
|
||||||
<UserPlus className="h-4 w-4" />
|
<UserPlus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenDeviceDialog(user)}
|
||||||
|
title="查看设备 RegistrationID"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
{user.status === 'active' ? (
|
{user.status === 'active' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -455,6 +499,87 @@ export default function Users() {
|
|||||||
onRemoveRole={handleRemoveRole}
|
onRemoveRole={handleRemoveRole}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 设备 RegistrationID 对话框 */}
|
||||||
|
<Dialog open={deviceDialogOpen} onOpenChange={setDeviceDialogOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>用户设备 / RegistrationID</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deviceUser
|
||||||
|
? `用户:${deviceUser.nickname || deviceUser.username}(@${deviceUser.username})的注册设备及推送 RegistrationID`
|
||||||
|
: '用户的注册设备及推送 RegistrationID'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{devicesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">该用户暂无注册设备</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[120px]">设备类型</TableHead>
|
||||||
|
<TableHead>设备名称 / ID</TableHead>
|
||||||
|
<TableHead>RegistrationID</TableHead>
|
||||||
|
<TableHead className="w-[80px]">状态</TableHead>
|
||||||
|
<TableHead className="w-[160px]">最后使用</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{devices.map((d) => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="uppercase">
|
||||||
|
{d.device_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{d.device_name || '-'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground font-mono break-all">{d.device_id}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{d.push_token ? (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-1 text-xs font-mono break-all bg-muted px-2 py-1 rounded">
|
||||||
|
{d.push_token}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(d.push_token!)}
|
||||||
|
title="复制 RegistrationID"
|
||||||
|
>
|
||||||
|
{copiedToken === d.push_token ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">未上报</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={d.is_active ? 'bg-green-500' : 'bg-gray-400'}>
|
||||||
|
{d.is_active ? '活跃' : '停用'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{d.last_used_at ? formatDate(d.last_used_at) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user