diff --git a/src/app/(main)/event-logs/page.tsx b/src/app/(main)/event-logs/page.tsx new file mode 100644 index 0000000..c8e1f2b --- /dev/null +++ b/src/app/(main)/event-logs/page.tsx @@ -0,0 +1,245 @@ +'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 | 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>({}); + // 排序(服务端) + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + // 近实时:自动刷新开关 + const [autoRefresh, setAutoRefresh] = useState(false); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/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 | 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('id'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; + + const columns: ColumnsType = [ + { + 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) => {v}, + }, + { + title: '页面', + dataIndex: 'page', + width: 100, + render: (v: string | null) => v || -, + }, + { + title: '用户', + dataIndex: 'user_id', + width: 80, + render: (v: number | null) => v ?? 未登录, + }, + { + title: '设备', + dataIndex: 'device_id', + width: 150, + ellipsis: true, + render: (v: string) => ( + + {v} + + ), + }, + { title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' }, + { + title: '机型', + key: 'oem', + width: 130, + render: (_: unknown, r: EventLog) => ( + {[r.oem, r.os].filter(Boolean).join(' / ') || '-'} + ), + }, + { title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' }, + ]; + + return ( +
+

埋点日志

+ + setEvent(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 180 }} + /> + setDeviceId(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 160 }} + /> + setUserId(e.target.value.replace(/\D/g, ''))} + onPressEnter={search} + allowClear + style={{ width: 100 }} + /> + setSessionId(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 160 }} + /> + setCreatedRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + + + + + + 自动刷新(5s) + + + + ( + + + 会话: {r.session_id || '-'} | 设备型号: {r.model || '-'} | 渠道: {r.channel || '-'} | IP:{' '} + {r.client_ip || '-'} + + + 端事件时间: {fmtMs(r.client_ts)} | 端上报时间: {fmtMs(r.sent_at)} + + + 属性: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'} + + + ), + }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: onPageChange, + }} + onChange={onTableChange} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 5ed4edd..6aac954 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { BarChartOutlined, DashboardOutlined, + DatabaseOutlined, FileSearchOutlined, FlagOutlined, HeartOutlined, @@ -39,6 +40,7 @@ const MENU = [ { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, + { key: '/event-logs', icon: , label: '埋点日志' }, { key: '/audit-logs', icon: , label: '审计日志' }, ];