({ 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}
-
- );
-}
diff --git a/src/pages/learning/MaterialDiagnosePage.tsx b/src/pages/learning/MaterialDiagnosePage.tsx
new file mode 100644
index 0000000..ce34c0b
--- /dev/null
+++ b/src/pages/learning/MaterialDiagnosePage.tsx
@@ -0,0 +1,24 @@
+import { useState } from 'react';
+import { Input, Space, Button } from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import { learningAPI } from '@/api';
+import { PageWrapper } from './PageWrapper';
+
+export default function MaterialDiagnosePage() {
+ const [materialId, setMaterialId] = useState('');
+ const { data, refetch } = useQuery({
+ queryKey: ['admin-mat-diag', materialId],
+ queryFn: () => learningAPI.getMaterialDiagnose(materialId),
+ enabled: false,
+ });
+
+ return (
+
+
+ setMaterialId(e.target.value)} style={{ width: 300 }} />
+
+
+ {data ? {JSON.stringify(data, null, 2)} : null}
+
+ );
+}
diff --git a/src/pages/learning/PageWrapper.tsx b/src/pages/learning/PageWrapper.tsx
new file mode 100644
index 0000000..90c83dd
--- /dev/null
+++ b/src/pages/learning/PageWrapper.tsx
@@ -0,0 +1,3 @@
+export function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
+ return {title}
{children};
+}
diff --git a/src/pages/learning/ProgressPage.tsx b/src/pages/learning/ProgressPage.tsx
new file mode 100644
index 0000000..9efc257
--- /dev/null
+++ b/src/pages/learning/ProgressPage.tsx
@@ -0,0 +1,77 @@
+import { useState } from 'react';
+import { Table, Input, Select, Space, Tag, Button, Drawer, Descriptions } from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import { learningAPI } from '@/api';
+import { progressStatusLabels, targetTypeLabels } from '@/constants/labels';
+import { PageWrapper } from './PageWrapper';
+import dayjs from 'dayjs';
+
+export default 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: () => learningAPI.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: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
+ { 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?: any) => {
+ if (!v) return '-';
+ const pct = v.scrollProgress != null ? `${Math.round(v.scrollProgress * 100)}%` : '';
+ const type = v.type || '';
+ if (type === 'Markdown' || type === 'Text') return `文本 · ${pct || '—'}`;
+ if (type === 'PDF' || type === 'Image') return `${type} · ${pct || '—'}`;
+ if (type === 'EPUB') return `EPUB · ${pct || '—'}`;
+ return pct || 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 }} />
+
+ ({ 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 ?? '-'}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/learning/ReadingEventPage.tsx b/src/pages/learning/ReadingEventPage.tsx
new file mode 100644
index 0000000..9150464
--- /dev/null
+++ b/src/pages/learning/ReadingEventPage.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react';
+import { Table, Input, Select, Space, Tag, Button, Drawer, Descriptions, DatePicker } from 'antd';
+import { useQuery } from '@tanstack/react-query';
+import { learningAPI } from '@/api';
+import { eventTypeLabels, eventStatusLabels } from '@/constants/labels';
+import { PageWrapper } from './PageWrapper';
+import dayjs from 'dayjs';
+
+const { RangePicker } = DatePicker;
+
+export default 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: () => learningAPI.getReadingEvents({ page, limit: 20, ...filters }),
+ });
+
+ const { data: detail, isLoading: detailLoading } = useQuery({
+ queryKey: ['admin-event-detail', detailId],
+ queryFn: () => learningAPI.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 (
+
+
+