Compare commits

..

1 Commits

Author SHA1 Message Date
陈世睿 6f4758e81d feat(device-liveness): 运营后台设备存活监控页(卡片/筛选/掉线置顶/刷新)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:06:52 +08:00
4 changed files with 288 additions and 247 deletions
+253
View File
@@ -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>
);
}
-245
View File
@@ -1,245 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { Button, DatePicker, Input, Space, Switch, Table, Tag, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
const { Text } = Typography;
const { RangePicker } = DatePicker;
/** 一条埋点日志(对应后端 analytics_event,五维)。 */
type EventLog = {
id: number;
event: string;
device_id: string;
user_id: number | null;
session_id: string | null;
client_ts: number;
sent_at: number | null;
created_at: string;
page: string | null;
client_ip: string | null;
oem: string | null;
os: string | null;
model: string | null;
app_ver: string | null;
network: string | null;
channel: string | null;
props: Record<string, string> | null;
};
type SortField = 'id' | 'created_at';
// 端时间是 epoch ms;格式化成「月-日 时:分:秒.毫秒」便于核对节流/顺序
const fmtMs = (ms: number | null) => (ms ? dayjs(ms).format('MM-DD HH:mm:ss.SSS') : '-');
export default function EventLogsPage() {
// 筛选草稿:点「查询」才应用
const [event, setEvent] = useState('');
const [deviceId, setDeviceId] = useState('');
const [userId, setUserId] = useState('');
const [sessionId, setSessionId] = useState('');
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
const [applied, setApplied] = useState<Record<string, unknown>>({});
// 排序(服务端)
const [sortBy, setSortBy] = useState<SortField>('id');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 近实时:自动刷新开关
const [autoRefresh, setAutoRefresh] = useState(false);
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<EventLog>('/admin/api/event-logs', filters);
// 开自动刷新后每 5s 拉当前页;reload 闭包随渲染更新,用 ref 取最新,避免 interval captured 过期闭包。
const reloadRef = useRef(reload);
reloadRef.current = reload;
useEffect(() => {
if (!autoRefresh) return;
const t = setInterval(() => reloadRef.current(), 5000);
return () => clearInterval(t);
}, [autoRefresh]);
const search = () =>
setApplied({
event: event.trim() || undefined,
device_id: deviceId.trim() || undefined,
user_id: userId.trim() ? Number(userId.trim()) : undefined,
session_id: sessionId.trim() || undefined,
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setEvent('');
setDeviceId('');
setUserId('');
setSessionId('');
setCreatedRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_p: unknown,
_f: unknown,
sorter: SorterResult<EventLog> | SorterResult<EventLog>[],
) => {
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('id');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const columns: ColumnsType<EventLog> = [
{
title: '接收时间',
dataIndex: 'created_at',
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{
title: '事件',
dataIndex: 'event',
width: 170,
render: (v: string) => <Tag color="blue">{v}</Tag>,
},
{
title: '页面',
dataIndex: 'page',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '用户',
dataIndex: 'user_id',
width: 80,
render: (v: number | null) => v ?? <Text type="secondary"></Text>,
},
{
title: '设备',
dataIndex: 'device_id',
width: 150,
ellipsis: true,
render: (v: string) => (
<Text style={{ fontSize: 12 }} copyable={{ text: v }}>
{v}
</Text>
),
},
{ title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' },
{
title: '机型',
key: 'oem',
width: 130,
render: (_: unknown, r: EventLog) => (
<Text style={{ fontSize: 12 }}>{[r.oem, r.os].filter(Boolean).join(' / ') || '-'}</Text>
),
},
{ title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' },
];
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="事件名 (如 video_play)"
value={event}
onChange={(e) => setEvent(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<Input
placeholder="设备ID(前缀)"
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 160 }}
/>
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
onPressEnter={search}
allowClear
style={{ width: 100 }}
/>
<Input
placeholder="会话ID"
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 160 }}
/>
<RangePicker
placeholder={['接收起', '接收止']}
value={createdRange}
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
<Button onClick={reload}></Button>
<Space size={4}>
<Switch checked={autoRefresh} onChange={setAutoRefresh} />
<Text type="secondary">(5s)</Text>
</Space>
</Space>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={items}
loading={loading}
expandable={{
expandedRowRender: (r) => (
<Space direction="vertical" size={2} style={{ fontSize: 12 }}>
<Text type="secondary">
: {r.session_id || '-'} : {r.model || '-'} : {r.channel || '-'} IP:{' '}
{r.client_ip || '-'}
</Text>
<Text type="secondary">
: {fmtMs(r.client_ts)} : {fmtMs(r.sent_at)}
</Text>
<Text type="secondary">
: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'}
</Text>
</Space>
),
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
onChange={onTableChange}
/>
</div>
);
}
+2 -2
View File
@@ -5,9 +5,9 @@ import { usePathname, useRouter } from 'next/navigation';
import {
BarChartOutlined,
DashboardOutlined,
DatabaseOutlined,
FileSearchOutlined,
FlagOutlined,
HeartOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
@@ -29,6 +29,7 @@ const MENU = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
@@ -38,7 +39,6 @@ const MENU = [
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
];
+33
View File
@@ -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;