feat(users): add user device management and registration ID display
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:
lafay
2026-06-18 20:08:06 +08:00
parent 4d25bf00ba
commit 1ef3a55359
3 changed files with 146 additions and 3 deletions

View File

@@ -1,7 +1,7 @@
export { default as apiClient } from './client'
export { authApi } from './auth'
export { usersApi } from './users'
export type { UserQueryParams, UserDetail } from './users'
export type { UserQueryParams, UserDetail, UserDevice } from './users'
export { rolesApi } from './roles'
export type { Permission, RoleInfo, RoleDetail } from './roles'
export { postsApi } from './posts'

View File

@@ -17,6 +17,20 @@ export interface UserDetail extends User {
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
export const usersApi = {
// 获取用户列表(分页、搜索、筛选)
@@ -27,6 +41,10 @@ export const usersApi = {
getUserById: (id: string) =>
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') =>
apiClient.put<ApiResponse<void>>(`/admin/users/${id}/status`, { status }),

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -31,7 +31,7 @@ import {
import { PaginationNavigator } from '@/components/ui/pagination'
import UserDetail from '@/components/UserDetail'
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> = {
@@ -75,6 +75,13 @@ export default function Users() {
const [roleDialogUser, setRoleDialogUser] = useState<User | null>(null)
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 () => {
setLoading(true)
@@ -149,6 +156,35 @@ export default function Users() {
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) => {
await usersApi.assignRole(userId, role)
@@ -365,6 +401,14 @@ export default function Users() {
>
<UserPlus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDeviceDialog(user)}
title="查看设备 RegistrationID"
>
<Smartphone className="h-4 w-4" />
</Button>
{user.status === 'active' ? (
<Button
variant="ghost"
@@ -455,6 +499,87 @@ export default function Users() {
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>
)
}