diff --git a/src/pages/learning/DataPages.tsx b/src/pages/learning/DataPages.tsx
index e69de29..a5ad02c 100644
--- a/src/pages/learning/DataPages.tsx
+++ b/src/pages/learning/DataPages.tsx
@@ -0,0 +1,512 @@
+import { useState } from 'react';
+import { Table, Input, Select, Space, Tag, Button, Tabs, Drawer, Descriptions, DatePicker, Timeline, Typography } from 'antd';
+
+const { Text } = Typography;
+import { useQuery } from '@tanstack/react-query';
+import { learningAdminAPI } from '../../services/learningAdmin';
+import { eventTypeLabels, eventStatusLabels, sessionStatusLabels, progressStatusLabels, targetTypeLabels, recordTypeLabels } from '../../constants/labels';
+import dayjs from 'dayjs';
+
+const { RangePicker } = DatePicker;
+
+function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
+ return
{title}
{children};
+}
+
+// ── Reading Events ──
+
+export function ReadingEventPage() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState>(() => {
+ const params = new URLSearchParams(window.location.search);
+ const init: Record = {};
+ const csid = params.get('clientSessionId');
+ if (csid) init.clientSessionId = csid;
+ return init;
+ });
+ const [detailId, setDetailId] = useState(null);
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-events', page, filters],
+ queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, ...filters }),
+ });
+
+ const { data: detail, isLoading: detailLoading } = useQuery({
+ queryKey: ['admin-event-detail', detailId],
+ queryFn: () => learningAdminAPI.getReadingEventDetail(detailId!),
+ enabled: !!detailId,
+ });
+
+ const columns = [
+ { title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
+ { title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
+ { title: '类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => {eventTypeLabels[t] || t} },
+ { title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 },
+ { title: '状态', dataIndex: 'status', key: 'status', width: 90,
+ render: (s: string) => {eventStatusLabels[s] || s} },
+ { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
+ render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
+ ];
+
+ return (
+
+
+ setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
+
+ ({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} />
+
+ setDetailId(null)} width={640} loading={detailLoading}>
+ {detail && (
+
+ {detail.eventId}
+ {detail.userId}
+ {detail.materialId}
+ {detail.eventType}
+ {detail.status}
+ {detail.activeSecondsDelta ?? '-'}
+ {detail.sequence ?? '-'}
+ {detail.clientSessionId ?? '-'}
+ {detail.readingTargetType ?? '-'}
+ {detail.platform ?? '-'}
+ {detail.appVersion ?? '-'}
+ {detail.clientTimezoneOffsetMinutes ?? '-'}
+ {detail.errorCode || '-'}
+ {detail.retryCount ?? 0}
+ {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.knowledgeBaseId || '-'}
+
+ )}
+
+
+ );
+}
+
+// ── Sessions ──
+
+export function SessionPage() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState>({});
+ const [detailId, setDetailId] = useState(null);
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-sessions', page, filters],
+ queryFn: () => learningAdminAPI.getSessions({ page, limit: 20, ...filters }),
+ });
+
+ const { data: detail, isLoading: detailLoading } = useQuery({
+ queryKey: ['admin-session-detail', detailId],
+ queryFn: () => learningAdminAPI.getSessionDetail(detailId!),
+ enabled: !!detailId,
+ });
+
+ const columns = [
+ { title: 'Session ID', dataIndex: 'id', key: 'id', width: 180, ellipsis: true },
+ { title: 'Client ID', dataIndex: 'clientSessionId', key: 'clientId', width: 140, ellipsis: true, render: (v?: string) => v?.slice(0, 12) || '-' },
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
+ { title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 90, ellipsis: true },
+ { title: '类型', dataIndex: 'targetType', key: 'targetType', width: 80, render: (v?: string) => v ? {targetTypeLabels[v] || v} : '-' },
+ { title: '模式', dataIndex: 'mode', key: 'mode', width: 70, render: (v?: string) => v || '-' },
+ { title: 'KB', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 80, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
+ { title: '状态', dataIndex: 'status', key: 'status', width: 90,
+ render: (s: string) => {sessionStatusLabels[s] || s} },
+ { title: '时长(s)', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 70 },
+ { title: '专注(分)', dataIndex: 'focusMinutes', key: 'focus', width: 70, render: (v?: number) => v ?? '-' },
+ { title: '开始', dataIndex: 'startedAt', key: 'startedAt', width: 150, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
+ { title: '结束', dataIndex: 'endedAt', key: 'endedAt', width: 150, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
+ { title: '最后事件', dataIndex: 'lastEventAt', key: 'lastEventAt', width: 150, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
+ ];
+
+ return (
+
+
+ setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
+ setFilters(f => ({ ...f, status: v || '' }))}
+ options={[{ value: 'active', label: '活跃' }, { value: 'interrupted', label: '中断' }, { value: 'completed', label: '已完成' }]} />
+
+
+ ({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} />
+
+ setDetailId(null)} width={680} loading={detailLoading}>
+ {detail && (
+
+ {detail.id}
+ {detail.clientSessionId || '-'}
+ {detail.userId}
+ {detail.materialId}
+ {detail.targetType || '-'}
+ {detail.mode || '-'}
+ {detail.knowledgeBaseId || '-'}
+ {detail.status}
+ {detail.totalActiveSeconds ?? '-'}
+ {detail.focusMinutes ?? '-'}
+ {detail.interrupted ? 是 : '否'}
+ {detail.missingMaterialClosed ? 是 : '否'}
+ {detail.abnormalDelta ? {detail.abnormalDelta} : '-'}
+ {detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.eventCount ?? '-'}
+ {detail.durationBreakdown || '-'}
+
+ )}
+ {detail?.clientSessionId && (
+
+
+
+ )}
+
+
+ );
+}
+
+// ── Progress ──
+
+export function ProgressPage() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState>({});
+ const [detail, setDetail] = useState(null);
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-progress', page, filters],
+ queryFn: () => learningAdminAPI.getProgress({ page, limit: 20, ...filters }),
+ });
+
+ const columns = [
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
+ { title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 110, ellipsis: true },
+ { title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => v ? {targetTypeLabels[v] || v} : '-' },
+ { title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 75, ellipsis: true, render: (v?: string) => v?.slice(0,8) || '-' },
+ { title: 'Session', dataIndex: 'lastClientSessionId', key: 'sessionId', width: 120, ellipsis: true, render: (v?: string) => v?.slice(0,16) || '-' },
+ { title: '状态', dataIndex: 'status', key: 'status', width: 85, render: (s: string) => {progressStatusLabels[s] || s} },
+ { title: '进度', dataIndex: 'lastProgress', key: 'progress', width: 65, render: (v: number) => v != null ? `${Math.round(v * 100)}%` : '-' },
+ { title: '位置', dataIndex: 'lastPosition', key: 'position', width: 100, ellipsis: true, render: (v?: object) => v ? JSON.stringify(v).slice(0,20) : '-' },
+ { title: '活跃秒', dataIndex: 'totalActiveSeconds', key: 'seconds', width: 70 },
+ { title: '已读', dataIndex: 'isMarkedRead', key: 'marked', width: 55, render: (v: boolean) => v ? '✅' : '' },
+ { title: '更新', dataIndex: 'lastReadAt', key: 'updatedAt', width: 140, render: (t?: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
+ ];
+
+ return (
+
+
+ setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, status: v || '' }))}
+ options={[{ value: 'reading', label: '阅读中' }, { value: 'completed', label: '已完成' }, { value: 'not_started', label: '未开始' }]} />
+
+
+ ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
+
+ setDetail(null)} width={600}>
+ {detail && (
+ <>
+
+ {detail.userId}
+ {detail.materialId}
+ {detail.readingTargetType || '-'}
+ {detail.knowledgeBaseId || '-'}
+ {detail.status}
+ {detail.isMarkedRead ? '✅' : '否'}
+ {detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'}
+ {detail.totalActiveSeconds ?? '-'}
+ {detail.lastClientSessionId || '-'}
+ {detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}
+ {detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.relatedMaterials?.join(', ') || '-'}
+ {detail.eventCount ?? '-'}
+ {detail.sessionCount ?? '-'}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
+
+// ── Daily Activities ──
+
+export function DailyActivityPage() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState>({});
+ const [detail, setDetail] = useState(null);
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-daily', page, filters],
+ queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20, ...filters }),
+ });
+
+ const columns = [
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
+ { title: '日期', dataIndex: 'activityDate', key: 'date', width: 120, render: (t: string) => t ? dayjs(t).format('YYYY-MM-DD') : '-' },
+ { title: '阅读秒', dataIndex: 'readingSeconds', key: 'seconds', width: 80,
+ render: (v: number) => 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v} },
+ { title: '资料数', dataIndex: 'materialsReadCount', key: 'matCount', width: 70 },
+ { title: '已读数', dataIndex: 'markedReadCount', key: 'marked', width: 70 },
+ { title: '专注(分)', dataIndex: 'focusMinutes', key: 'focus', width: 70, render: (v?: number) => v ?? '-' },
+ { title: '会话数', dataIndex: 'sessionCount', key: 'sessions', width: 70, render: (v?: number) => v ?? '-' },
+ { title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kb', width: 80, ellipsis: true, render: (v?: string) => v?.slice(0,8) || '-' },
+ ];
+
+ return (
+
+
+ setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, knowledgeBaseId: v || '' }))} options={[]} />
+ {
+ if (dates?.[0] && dates?.[1]) setFilters(f => ({ ...f, startDate: dates[0]!.format('YYYY-MM-DD'), endDate: dates[1]!.format('YYYY-MM-DD') }));
+ else setFilters(f => { const { startDate, endDate, ...rest } = f; return rest; });
+ }} />
+
+
+ ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
+
+ setDetail(null)} width={520}>
+ {detail && (
+
+ {detail.userId}
+ {detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'}
+ {detail.readingSeconds ?? 0}
+ {detail.focusMinutes ?? '-'}
+ {detail.materialsReadCount ?? 0}
+ {detail.markedReadCount ?? 0}
+ {detail.sessionCount ?? '-'}
+ {detail.knowledgeBaseId || '-'}
+ {detail.eventCount ?? '-'}
+ {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+
+ )}
+
+
+ );
+}
+
+// ── Records ──
+
+export function RecordPage() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState>({});
+ const [detail, setDetail] = useState(null);
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-records', page, filters],
+ queryFn: () => learningAdminAPI.getRecords({ page, limit: 20, ...filters }),
+ });
+
+ const columns = [
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
+ { title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true, render: (_: any, r: any) => r.materialName || (r.materialId ? r.materialId.slice(0,12) : '-') },
+ { title: '标题', dataIndex: 'title', key: 'title', width: 180 },
+ { title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => {recordTypeLabels[t] || t} },
+ { title: '时长', dataIndex: 'durationSeconds', key: 'duration', width: 80 },
+ { title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
+ ];
+
+ return (
+
+
+ setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
+ setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
+
+
+ ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
+
+ setDetail(null)} width={500}>
+ {detail && (
+
+ {detail.userId}
+ {detail.recordType}
+ {detail.title || '-'}
+ {detail.durationSeconds ?? '-'}
+ {detail.materialId || '-'}
+ {detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+ {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+
+ )}
+
+
+ );
+}
+
+// ── Failed / Warning / Duplicate Events ──
+
+export function AnomalyPage() {
+ const [activeTab, setActiveTab] = useState('failed');
+ const [page, setPage] = useState(1);
+ const [errorCodeFilter, setErrorCodeFilter] = useState('');
+ const [detailId, setDetailId] = useState(null);
+
+ const statusParam = activeTab === 'warning' ? 'warning' : activeTab === 'duplicate' ? 'duplicate' : 'failed';
+ const { data, isLoading } = useQuery({
+ queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter],
+ queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }),
+ });
+
+ const { data: detail, isLoading: detailLoading } = useQuery({
+ queryKey: ['admin-anomaly-detail', detailId],
+ queryFn: () => learningAdminAPI.getReadingEventDetail(detailId!),
+ enabled: !!detailId,
+ });
+
+ const columns = [
+ { title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
+ { title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
+ { title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
+ { title: '事件类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => {eventTypeLabels[t] || t} },
+ { title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140,
+ render: (c?: string) => c ? {c} : '-' },
+ { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
+ render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
+ ];
+
+ const statusColor = (s: string) => s === 'failed' ? 'red' : s === 'warning' ? 'orange' : 'blue';
+
+ return (
+
+
+ { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} />
+
+ { setActiveTab(k); setPage(1); }}
+ items={[
+ { key: 'failed', label: 失败事件 {data?.total || 0},
+ children: ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
+ { key: 'warning', label: 警告事件 {data?.total || 0},
+ children: ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
+ { key: 'duplicate', label: 重复事件 {data?.total || 0},
+ children: ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
+ ]} />
+
+ setDetailId(null)} width={640} loading={detailLoading}>
+ {detail && (
+
+ {detail.eventId}
+ {detail.userId}
+ {detail.materialId}
+ {detail.eventType}
+ {detail.status}
+ {detail.errorCode || '-'}
+ {detail.errorMessage || '-'}
+ {detail.retryCount ?? 0}
+ {detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+
+ )}
+
+
+ );
+}
+
+// ── User Timeline ──
+
+export function UserTimelinePage() {
+ const [userId, setUserId] = useState('');
+ const { data, isFetching, refetch } = useQuery({
+ queryKey: ['admin-user-timeline', userId],
+ queryFn: () => learningAdminAPI.getUserTimeline(userId),
+ enabled: false,
+ });
+
+ const items = Array.isArray(data)
+ ? (data as any[]).map((entry: any, i: number) => ({
+ key: i,
+ color: entry.recordType === 'session' ? 'blue' : entry.recordType === 'reading' ? 'green' : 'gray',
+ children: (
+
+
{entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
+
{recordTypeLabels[entry.recordType] || entry.recordType}
+
{entry.title || '-'}
+ {entry.durationSeconds != null &&
时长 {entry.durationSeconds}s
}
+ {entry.materialId &&
资料 {entry.materialId}
}
+
+ ),
+ }))
+ : [];
+
+ return (
+
+
+ setUserId(e.target.value)} style={{ width: 300 }} />
+
+
+ {data === undefined && !isFetching && 输入用户 ID 后点击查询}
+ {isFetching && 加载中...}
+ {data && !Array.isArray(data) ? {JSON.stringify(data as any, null, 2)} : null}
+ {Array.isArray(data) && data.length === 0 && !isFetching && 该用户暂无学习记录}
+ {items.length > 0 && }
+
+ );
+}
+
+// ── User Diagnose ──
+
+export function UserDiagnosePage() {
+ const [userId, setUserId] = useState('');
+ const { data, refetch } = useQuery({
+ queryKey: ['admin-user-diag', userId],
+ queryFn: () => learningAdminAPI.getUserDiagnose(userId),
+ enabled: false,
+ });
+
+ return (
+
+
+ setUserId(e.target.value)} style={{ width: 300 }} />
+
+
+ {data ? {JSON.stringify(data, null, 2)} : null}
+
+ );
+}
+
+// ── Material Diagnose ──
+
+export function MaterialDiagnosePage() {
+ const [materialId, setMaterialId] = useState('');
+ const { data, refetch } = useQuery({
+ queryKey: ['admin-mat-diag', materialId],
+ queryFn: () => learningAdminAPI.getMaterialDiagnose(materialId),
+ enabled: false,
+ });
+
+ return (
+
+
+ setMaterialId(e.target.value)} style={{ width: 300 }} />
+
+
+ {data ? {JSON.stringify(data, null, 2)} : null}
+
+ );
+}