feat(notification): show device monitoring stats

This commit is contained in:
2026-09-10 20:04:02 +08:00
parent b6b5119e0f
commit 573b0c4bc7
3 changed files with 71 additions and 23 deletions
+11 -1
View File
@@ -1,6 +1,16 @@
import { api } from '@/api/client' 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) { export function fetchDevices(params: ListParams) {
return api.get<PaginatedResponse<NotificationDevice>>('/api/admin/v1/notification/devices', params) 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
View File
@@ -389,15 +389,25 @@ export interface LocaleRow {
} }
export interface NotificationDevice { export interface NotificationDevice {
id: number | string kind: 'device'
device_id?: string id: string
user_uid?: string | null device_client_id: string
platform?: string platform: 'ios' | 'android' | 'web' | 'desktop'
model?: string | null push_provider: string | null
is_online?: boolean app_key: string | null
active_sessions_count?: number device_model: string | null
last_seen_at?: string | null is_online: boolean
created_at?: string 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' export type ReportStatus = 'open' | 'resolved' | 'dismissed'
+41 -13
View File
@@ -1,13 +1,15 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PERM, requirePermission } from '@/auth/permissions' import { PERM, requirePermission } from '@/auth/permissions'
import { useState } from 'react' import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import type { NotificationDevice } from '@/api/types' 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 { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table' import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -26,9 +28,9 @@ const columns: ColumnDef<NotificationDevice>[] = [
header: '设备', header: '设备',
cell: ({ row }) => ( cell: ({ row }) => (
<div> <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"> <code className="font-mono text-xs text-muted-foreground">
{String(row.original.device_id ?? row.original.id)} {row.original.device_client_id}
</code> </code>
</div> </div>
), ),
@@ -37,10 +39,8 @@ const columns: ColumnDef<NotificationDevice>[] = [
header: '平台', header: '平台',
cell: ({ row }) => <Badge variant="secondary">{row.original.platform ?? '—'}</Badge>, cell: ({ row }) => <Badge variant="secondary">{row.original.platform ?? '—'}</Badge>,
}, },
{ { header: '应用', cell: ({ row }) => row.original.app_key ?? '—' },
header: '用户', { header: '推送', cell: ({ row }) => (row.original.has_push_token ? row.original.push_provider ?? '已配置' : '未配置') },
cell: ({ row }) => <code className="font-mono text-xs">{row.original.user_uid ?? '未绑定'}</code>,
},
{ {
header: '在线', header: '在线',
cell: ({ row }) => cell: ({ row }) =>
@@ -63,22 +63,35 @@ const columns: ColumnDef<NotificationDevice>[] = [
function DevicesPage() { function DevicesPage() {
const [platform, setPlatform] = useState<string>('all') const [platform, setPlatform] = useState<string>('all')
const [online, setOnline] = useState<string>('all')
const stats = useQuery({ queryKey: ['device-stats'], queryFn: fetchDeviceStats })
const list = useListPage( const list = useListPage(
'devices', 'devices',
fetchDevices, fetchDevices,
platform === 'all' ? {} : { platform }, {
...(platform === 'all' ? {} : { platform }),
...(online === 'all' ? {} : { is_online: online === 'online' }),
},
) )
const stat = stats.data?.data
return ( return (
<div> <div>
<PageHeader <PageHeader
title="推送设备" title="推送设备"
description="注册的通知设备与在线状态" 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}> <Select value={platform} onValueChange={setPlatform}>
<SelectTrigger size="sm" className="w-32"> <SelectTrigger size="sm" className="w-32"><SelectValue /></SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="ios">iOS</SelectItem> <SelectItem value="ios">iOS</SelectItem>
@@ -87,8 +100,14 @@ function DevicesPage() {
<SelectItem value="desktop">Desktop</SelectItem> <SelectItem value="desktop">Desktop</SelectItem>
</SelectContent> </SelectContent>
</Select> </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 <DataTable
columns={columns} columns={columns}
data={list.rows} data={list.rows}
@@ -100,3 +119,12 @@ function DevicesPage() {
</div> </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>
)
}