feat(device-liveness): 运营后台设备存活监控页(卡片/筛选/掉线置顶/刷新) (#20)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: 陈世睿 <2839904623@qq.com> Reviewed-on: #20 Co-authored-by: chenshirui <chenshirui@wonderable.ai> Co-committed-by: chenshirui <chenshirui@wonderable.ai>
This commit was merged in pull request #20.
This commit is contained in:
@@ -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<string, { color: string; label: string }> = {
|
||||||
|
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) => (
|
||||||
|
<Tooltip title={hint}>
|
||||||
|
<Tag color="default">待接入</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DeviceLivenessPage() {
|
||||||
|
// 筛选草稿:点「查询」才应用
|
||||||
|
const [deviceId, setDeviceId] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [status, setStatus] = useState<string | undefined>();
|
||||||
|
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||||
|
// 排序:默认 status = 掉线置顶;点「最近心跳」列头切换
|
||||||
|
const [sortBy, setSortBy] = useState<SortField>('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<DeviceLivenessItem>(
|
||||||
|
'/admin/api/device-liveness',
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 顶部卡片统计(全局,不随筛选)
|
||||||
|
const [stats, setStats] = useState<DeviceLivenessStats | null>(null);
|
||||||
|
const loadStats = useCallback(() => {
|
||||||
|
api.get<DeviceLivenessStats>('/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<DeviceLivenessItem> | SorterResult<DeviceLivenessItem>[],
|
||||||
|
) => {
|
||||||
|
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<DeviceLivenessItem> = [
|
||||||
|
{ title: '设备 ID', dataIndex: 'device_id', fixed: 'left', width: 210, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '归属用户',
|
||||||
|
key: 'user',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, d: DeviceLivenessItem) => (
|
||||||
|
<div>
|
||||||
|
<div>{d.phone || `user#${d.user_id}`}</div>
|
||||||
|
{d.nickname && <div style={{ color: '#888', fontSize: 12 }}>{d.nickname}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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 ? <Tooltip title={formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss')}>{utcFromNow(v)}</Tooltip> : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '存活状态',
|
||||||
|
dataIndex: 'display_state',
|
||||||
|
width: 130,
|
||||||
|
render: (s: string, d: DeviceLivenessItem) => {
|
||||||
|
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tag color={t.color}>{t.label}</Tag>
|
||||||
|
{s === 'offline' && d.offline_seconds != null && (
|
||||||
|
<div style={{ color: '#cf1322', fontSize: 12 }}>已 {fmtDuration(d.offline_seconds)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '首次无障碍开启时间',
|
||||||
|
dataIndex: 'first_protected_at',
|
||||||
|
width: 150,
|
||||||
|
render: (v: string | null) =>
|
||||||
|
v ? (
|
||||||
|
formatUtcTime(v)
|
||||||
|
) : (
|
||||||
|
<Tooltip title="迁移前已开过无障碍的老设备无此记录;之后首次开无障碍的设备会记录">
|
||||||
|
<span style={{ color: '#bbb' }}>-</span>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>设备存活监控</h2>
|
||||||
|
|
||||||
|
{stats && (
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Col key={c.title} xs={12} sm={6} md={6} xl={4}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={c.title}
|
||||||
|
value={c.value}
|
||||||
|
valueStyle={c.color ? { color: c.color } : undefined}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
placeholder="存活状态"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'offline', label: '已掉线' },
|
||||||
|
{ value: 'online', label: '在线' },
|
||||||
|
{ value: 'never', label: '未启用' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="设备ID(包含)"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
onPressEnter={search}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="归属用户手机号(前缀)"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
onPressEnter={search}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 180 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={search}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button onClick={resetFilters}>重置</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 台设备`,
|
||||||
|
onChange,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
onChange={onTableChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
|
HeartOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
@@ -28,6 +29,7 @@ const MENU = [
|
|||||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
||||||
|
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||||
|
|||||||
@@ -44,6 +44,39 @@ export interface DeviceOnboardingItem {
|
|||||||
last_completed_at: string;
|
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 {
|
export interface UserOverview {
|
||||||
user: UserListItem;
|
user: UserListItem;
|
||||||
coin_balance: number;
|
coin_balance: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user