From 5f2511e7cdea2a71a6c7abd2bb3f41fad1ed37e9 Mon Sep 17 00:00:00 2001 From: chenshirui Date: Fri, 26 Jun 2026 15:28:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(device-liveness):=20=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E8=AE=BE=E5=A4=87=E5=AD=98=E6=B4=BB=E7=9B=91?= =?UTF-8?q?=E6=8E=A7=E9=A1=B5(=E5=8D=A1=E7=89=87/=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E6=8E=89=E7=BA=BF=E7=BD=AE=E9=A1=B6/=E5=88=B7=E6=96=B0)=20(#20?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: 陈世睿 <2839904623@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/20 Co-authored-by: chenshirui Co-committed-by: chenshirui --- src/app/(main)/device-liveness/page.tsx | 253 ++++++++++++++++++++++++ src/app/(main)/layout.tsx | 2 + src/lib/types.ts | 33 ++++ 3 files changed, 288 insertions(+) create mode 100644 src/app/(main)/device-liveness/page.tsx diff --git a/src/app/(main)/device-liveness/page.tsx b/src/app/(main)/device-liveness/page.tsx new file mode 100644 index 0000000..395e224 --- /dev/null +++ b/src/app/(main)/device-liveness/page.tsx @@ -0,0 +1,253 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import type { SorterResult } from 'antd/es/table/interface'; +import { ReloadOutlined } from '@ant-design/icons'; +import { Button, Card, Col, Input, Row, Select, Space, Statistic, Table, Tag, Tooltip } from 'antd'; +import { api } from '@/lib/api'; +import { formatUtcTime, utcFromNow } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; +import type { DeviceLivenessItem, DeviceLivenessStats } from '@/lib/types'; + +type SortField = 'status' | 'last_heartbeat_at' | 'created_at'; + +// 存活状态(后端按心跳阈值派生 display_state)→ 标签。operator 口径,不暴露内部 alive/notified/silent。 +const STATUS_TAG: Record = { + online: { color: 'green', label: '在线' }, + offline: { color: 'red', label: '已掉线' }, + never: { color: 'default', label: '未启用' }, +}; + +/** 秒 → 人话时长(掉线时长用)。 */ +function fmtDuration(sec: number | null): string { + if (sec == null) return ''; + if (sec < 60) return `${sec} 秒`; + const m = Math.floor(sec / 60); + if (m < 60) return `${m} 分钟`; + const h = Math.floor(m / 60); + if (h < 24) return `${h} 小时 ${m % 60} 分`; + const d = Math.floor(h / 24); + return `${d} 天 ${h % 24} 小时`; +} + +// 暂无数据的字段统一占位(待客户端上报 / 待加字段)。 +const pending = (hint: string) => ( + + 待接入 + +); + +export default function DeviceLivenessPage() { + // 筛选草稿:点「查询」才应用 + const [deviceId, setDeviceId] = useState(''); + const [phone, setPhone] = useState(''); + const [status, setStatus] = useState(); + const [applied, setApplied] = useState>({}); + // 排序:默认 status = 掉线置顶;点「最近心跳」列头切换 + const [sortBy, setSortBy] = useState('status'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const { items, total, page, pageSize, loading, onChange, reload } = usePagedList( + '/admin/api/device-liveness', + filters, + ); + + // 顶部卡片统计(全局,不随筛选) + const [stats, setStats] = useState(null); + const loadStats = useCallback(() => { + api.get('/admin/api/device-liveness/stats').then((r) => setStats(r.data)); + }, []); + useEffect(() => { + loadStats(); + }, [loadStats]); + + const search = () => + setApplied({ + status, + device_id: deviceId || undefined, + phone: phone || undefined, + }); + + const resetFilters = () => { + setDeviceId(''); + setPhone(''); + setStatus(undefined); + setSortBy('status'); + setSortOrder('desc'); + setApplied({}); + }; + + const refreshAll = () => { + reload(); + loadStats(); + }; + + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + // 取消列排序 → 回默认「掉线置顶」 + setSortBy('status'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + + const cards = stats + ? [ + { title: '总设备数', value: stats.total, color: undefined as string | undefined }, + { title: '在线', value: stats.online, color: '#3f8600' }, + { title: '已掉线', value: stats.offline, color: stats.offline > 0 ? '#cf1322' : undefined }, + { title: '未启用', value: stats.never, color: undefined }, + ] + : []; + + // 列顺序对齐产品给的字段清单 + const columns: ColumnsType = [ + { title: '设备 ID', dataIndex: 'device_id', fixed: 'left', width: 210, ellipsis: true }, + { + title: '归属用户', + key: 'user', + width: 150, + render: (_: unknown, d: DeviceLivenessItem) => ( +
+
{d.phone || `user#${d.user_id}`}
+ {d.nickname &&
{d.nickname}
} +
+ ), + }, + { title: '机型', dataIndex: 'device_model', width: 130, render: (v: string | null) => v || '-' }, + { + title: '系统&系统版本', + key: 'os', + width: 130, + render: () => pending('设备未上报系统版本,需客户端在注册/心跳里带上 + 后端加列'), + }, + { title: 'APP 版本', dataIndex: 'app_version', width: 100, render: (v: string | null) => v || '-' }, + { + title: '最近一次心跳', + dataIndex: 'last_heartbeat_at', + width: 150, + sorter: true, + sortOrder: sortOrderOf('last_heartbeat_at'), + render: (v: string | null) => + v ? {utcFromNow(v)} : '-', + }, + { + title: '存活状态', + dataIndex: 'display_state', + width: 130, + render: (s: string, d: DeviceLivenessItem) => { + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return ( +
+ {t.label} + {s === 'offline' && d.offline_seconds != null && ( +
已 {fmtDuration(d.offline_seconds)}
+ )} +
+ ); + }, + }, + { + title: '首次无障碍开启时间', + dataIndex: 'first_protected_at', + width: 150, + render: (v: string | null) => + v ? ( + formatUtcTime(v) + ) : ( + + - + + ), + }, + ]; + + return ( +
+

设备存活监控

+ + {stats && ( + + {cards.map((c) => ( + + + + + + ))} + + )} + + + setDeviceId(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 200 }} + /> + setPhone(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 180 }} + /> + + + + + + `共 ${t} 台设备`, + onChange, + }} + scroll={{ x: 1300 }} + onChange={onTableChange} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 70bf4cf..5ed4edd 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -7,6 +7,7 @@ import { DashboardOutlined, FileSearchOutlined, FlagOutlined, + HeartOutlined, LogoutOutlined, MessageOutlined, MobileOutlined, @@ -28,6 +29,7 @@ const MENU = [ { key: '/dashboard', icon: , label: '数据大盘' }, { key: '/users', icon: , label: '用户管理' }, { key: '/devices', icon: , label: '设备管理' }, + { key: '/device-liveness', icon: , label: '设备存活' }, { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, { key: '/comparison-records', icon: , label: '比价记录' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index cacc526..1ac18af 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -44,6 +44,39 @@ export interface DeviceOnboardingItem { last_completed_at: string; } +// 设备存活监控:device_liveness 表一行 + 后端按心跳阈值派生的在线/掉线/掉线时长。 +export interface DeviceLivenessItem { + id: number; + user_id: number; + phone: string | null; + nickname: string | null; + device_id: string; + device_model: string | null; // 由 device_id 解析的机型 + platform: string; + app_version: string | null; + registration_id: string | null; // 非空 = 可极光推送 + ever_protected: boolean; + first_protected_at: string | null; // 首次开无障碍时刻(老设备为 null) + last_heartbeat_at: string | null; + last_report_protection_on: boolean; + liveness_state: string; // unknown / alive / silent(当前未用) / notified + notified_at: string | null; + kill_alert_pending: boolean; // 掉线召回待客户端 ack + created_at: string; + updated_at: string; + // 后端派生 + online: boolean; + display_state: string; // online 在线 / offline 已掉线 / never 未启用 + offline_seconds: number | null; // 掉线时长(秒);仅 offline 有值 +} + +export interface DeviceLivenessStats { + total: number; + online: number; + offline: number; + never: number; +} + export interface UserOverview { user: UserListItem; coin_balance: number;