feat(notification): show device monitoring stats
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import { api } from '@/api/client'
|
||||
import type { ListParams, NotificationDevice, PaginatedResponse } from '@/api/types'
|
||||
import type {
|
||||
ApiResponse,
|
||||
ListParams,
|
||||
NotificationDevice,
|
||||
NotificationDeviceStats,
|
||||
PaginatedResponse,
|
||||
} from '@/api/types'
|
||||
|
||||
export function fetchDevices(params: ListParams) {
|
||||
return api.get<PaginatedResponse<NotificationDevice>>('/api/admin/v1/notification/devices', params)
|
||||
}
|
||||
|
||||
export function fetchDeviceStats() {
|
||||
return api.get<ApiResponse<NotificationDeviceStats>>('/api/admin/v1/notification/devices/stats')
|
||||
}
|
||||
|
||||
+19
-9
@@ -389,15 +389,25 @@ export interface LocaleRow {
|
||||
}
|
||||
|
||||
export interface NotificationDevice {
|
||||
id: number | string
|
||||
device_id?: string
|
||||
user_uid?: string | null
|
||||
platform?: string
|
||||
model?: string | null
|
||||
is_online?: boolean
|
||||
active_sessions_count?: number
|
||||
last_seen_at?: string | null
|
||||
created_at?: string
|
||||
kind: 'device'
|
||||
id: string
|
||||
device_client_id: string
|
||||
platform: 'ios' | 'android' | 'web' | 'desktop'
|
||||
push_provider: string | null
|
||||
app_key: string | null
|
||||
device_model: string | null
|
||||
is_online: boolean
|
||||
has_push_token: boolean
|
||||
active_sessions_count: number
|
||||
last_seen_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface NotificationDeviceStats {
|
||||
total_devices: number
|
||||
online_devices: number
|
||||
platform_stats: Partial<Record<NotificationDevice['platform'], number>>
|
||||
}
|
||||
|
||||
export type ReportStatus = 'open' | 'resolved' | 'dismissed'
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PERM, requirePermission } from '@/auth/permissions'
|
||||
import { useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { NotificationDevice } from '@/api/types'
|
||||
import { fetchDevices } from '@/api/modules/devices'
|
||||
import { fetchDevices, fetchDeviceStats } from '@/api/modules/devices'
|
||||
import { useListPage } from '@/lib/list-page'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,9 +28,9 @@ const columns: ColumnDef<NotificationDevice>[] = [
|
||||
header: '设备',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div className="font-medium">{row.original.model ?? row.original.device_id ?? '—'}</div>
|
||||
<div className="font-medium">{row.original.device_model ?? row.original.device_client_id}</div>
|
||||
<code className="font-mono text-xs text-muted-foreground">
|
||||
{String(row.original.device_id ?? row.original.id)}
|
||||
{row.original.device_client_id}
|
||||
</code>
|
||||
</div>
|
||||
),
|
||||
@@ -37,10 +39,8 @@ const columns: ColumnDef<NotificationDevice>[] = [
|
||||
header: '平台',
|
||||
cell: ({ row }) => <Badge variant="secondary">{row.original.platform ?? '—'}</Badge>,
|
||||
},
|
||||
{
|
||||
header: '用户',
|
||||
cell: ({ row }) => <code className="font-mono text-xs">{row.original.user_uid ?? '未绑定'}</code>,
|
||||
},
|
||||
{ header: '应用', cell: ({ row }) => row.original.app_key ?? '—' },
|
||||
{ header: '推送', cell: ({ row }) => (row.original.has_push_token ? row.original.push_provider ?? '已配置' : '未配置') },
|
||||
{
|
||||
header: '在线',
|
||||
cell: ({ row }) =>
|
||||
@@ -63,22 +63,35 @@ const columns: ColumnDef<NotificationDevice>[] = [
|
||||
|
||||
function DevicesPage() {
|
||||
const [platform, setPlatform] = useState<string>('all')
|
||||
const [online, setOnline] = useState<string>('all')
|
||||
const stats = useQuery({ queryKey: ['device-stats'], queryFn: fetchDeviceStats })
|
||||
const list = useListPage(
|
||||
'devices',
|
||||
fetchDevices,
|
||||
platform === 'all' ? {} : { platform },
|
||||
{
|
||||
...(platform === 'all' ? {} : { platform }),
|
||||
...(online === 'all' ? {} : { is_online: online === 'online' }),
|
||||
},
|
||||
)
|
||||
|
||||
const stat = stats.data?.data
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="推送设备"
|
||||
description="注册的通知设备与在线状态"
|
||||
actions={
|
||||
actions={<div className="flex gap-2">
|
||||
<Select value={online} onValueChange={setOnline}>
|
||||
<SelectTrigger size="sm" className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="online">在线</SelectItem>
|
||||
<SelectItem value="offline">离线</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={platform} onValueChange={setPlatform}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectTrigger size="sm" className="w-32"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部平台</SelectItem>
|
||||
<SelectItem value="ios">iOS</SelectItem>
|
||||
@@ -87,8 +100,14 @@ function DevicesPage() {
|
||||
<SelectItem value="desktop">Desktop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
</div>}
|
||||
/>
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<DeviceStatCard title="设备总数" value={stat?.total_devices} />
|
||||
<DeviceStatCard title="当前在线" value={stat?.online_devices} />
|
||||
<DeviceStatCard title="iOS" value={stat?.platform_stats.ios} />
|
||||
<DeviceStatCard title="Android" value={stat?.platform_stats.android} />
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={list.rows}
|
||||
@@ -100,3 +119,12 @@ function DevicesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceStatCard({ title, value }: { title: string; value?: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium">{title}</CardTitle></CardHeader>
|
||||
<CardContent><div className="text-2xl font-semibold">{value ?? '—'}</div></CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user