d3efb3c487
- src/app/(main)/event-logs/page.tsx:埋点日志列表,五维列 + 展开行看 props 扩展字段, 按 事件/设备/用户/会话/接收时间 筛选,自动刷新(5s),接收时间列置首 - layout 左侧菜单加「埋点日志」入口 - 对接 admin 后端 GET /admin/api/event-logs(见 app-server 同批 PR) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #21 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
246 lines
7.7 KiB
TypeScript
246 lines
7.7 KiB
TypeScript
'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>
|
||
);
|
||
}
|