Files
shaguabijia-admin-web/src/app/(main)/event-logs/page.tsx
T
zhuzihao d3efb3c487 feat(analytics): 新增「埋点日志」管理页 (#21)
- 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>
2026-06-26 23:33:35 +08:00

246 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}