diff --git a/docs/admin-learning-info-ops-design.md b/docs/admin-learning-info-ops-design.md new file mode 100644 index 0000000..a5f6844 --- /dev/null +++ b/docs/admin-learning-info-ops-design.md @@ -0,0 +1,218 @@ +# Admin 学习信息运维模块 总设计 + +> ADMIN-INFO-000 | v1.0 | 2026-06-12 + +## 1. 概述 + +Admin 学习信息运维模块是运营人员查看、诊断、修复学习信息数据的 Web 管理后台。本里程碑只做 Admin 前端,所有数据通过 API-ADMIN-INFO 接口获取,mock 只能用于开发调试,不得作为最终验收。 + +## 2. 职责边界 + +| 层 | 负责 | 不负责 | +|----|------|--------| +| **Admin 前端** | 页面渲染、筛选、分页、图表、操作按钮 | 数据聚合、权限校验、落库 | +| **API-ADMIN-INFO** | 数据查询、权限校验、分页、导出、重算 | 前端 UI | +| **业务 API** | 事件采集、分析、题目/卡片生成 | Admin 查询 | + +## 3. 技术栈 + +- React 19 + TypeScript +- Vite (build) +- Ant Design 5 + @ant-design/pro-components (ProTable) +- @tanstack/react-query (data fetching) +- ECharts (charts) +- react-router-dom v6 (routing) +- TailwindCSS 4 (utilities) + +## 4. 页面路由结构 + +``` +/admin/learning/ +├── dashboard # ADMIN-INFO-001 +├── events +│ ├── list # ADMIN-INFO-002 (ReadingEvent) +│ ├── :eventId # ADMIN-INFO-002 (详情) +│ ├── failed # ADMIN-INFO-003 (failed/warning/duplicate) +│ └── replay # ADMIN-INFO-004 (重放) +├── sessions +│ ├── list # ADMIN-INFO-005 +│ ├── :sessionId # ADMIN-INFO-005 (详情) +│ └── interrupted # ADMIN-INFO-013 +├── progress +│ └── list # ADMIN-INFO-006 +├── activity +│ ├── daily # ADMIN-INFO-007 (日历/热力图) +│ └── timeline/:userId # ADMIN-INFO-009 (用户时间线) +├── records +│ └── list # ADMIN-INFO-008 +├── export # P1 +└── audit # P1 +``` + +## 5. API 端点映射 + +| 页面 | Issue | API 端点 | 方法 | +|------|-------|----------|------| +| Dashboard | 001 | `/admin/learning/dashboard` | GET | +| ReadingEvent 列表 | 002 | `/admin/learning/events` | GET | +| ReadingEvent 详情 | 002 | `/admin/learning/events/:id` | GET | +| failed/warning/duplicate | 003 | `/admin/learning/events?status=failed&status=warning&status=duplicate` | GET | +| 重放/修复 | 004 | `/admin/learning/events/:id/replay` | POST | +| Session 列表 | 005 | `/admin/learning/sessions` | GET | +| Session 详情 | 005 | `/admin/learning/sessions/:id` | GET | +| MaterialProgress | 006 | `/admin/learning/progress` | GET | +| DailyActivity | 007 | `/admin/learning/activity` | GET | +| LearningRecord | 008 | `/admin/learning/records` | GET | +| 用户时间线 | 009 | `/admin/learning/activity?userId=...` | GET | +| interrupted session | 013 | `/admin/learning/sessions?status=interrupted` | GET | +| TemporaryReadingMaterial | P1 | `/admin/learning/temporary-materials` | GET | + +> **TemporaryReadingMaterial**(P1):临时文件阅读数据管理,包括列表、详情、过期清理。API 路径待 API-ADMIN-INFO-014 定义后确认。 + +所有接口统一前缀 `/admin/learning/`,查询参数支持 `page`/`pageSize`/`userId`/`materialId`/`eventType`/`status`/`startDate`/`endDate`/`knowledgeBaseId`。 + +## 6. API Service 层 + +``` +src/services/learningAdmin.ts +├── getDashboard(params) → DashboardDTO +├── getEvents(params) → PaginatedResponse +├── getEventDetail(id) → ReadingEventDTO +├── replayEvent(id) → ReplayResultDTO +├── getSessions(params) → PaginatedResponse +├── getSessionDetail(id) → SessionDTO +├── getProgressList(params) → PaginatedResponse +├── getDailyActivity(params) → DailyActivityDTO[] +├── getRecords(params) → PaginatedResponse +├── getUserTimeline(userId, params) → TimelineDTO[] +└── getInterruptedSessions(params) → PaginatedResponse +``` + +统一使用 `@tanstack/react-query` 管理缓存和加载状态。所有请求通过 `http-client.ts` 发送,自动携带 admin token。 + +## 7. 类型系统 + +``` +src/types/admin.ts (扩展) +├── ReadingEventDTO +├── SessionDTO +├── MaterialProgressDTO +├── DailyActivityDTO +├── LearningRecordDTO +├── TimelineDTO +├── DashboardDTO +├── PaginatedResponse +├── EventFilterParams +├── SessionFilterParams +``` + +DTO 字段与 API-ADMIN-INFO 响应保持一致,使用 camelCase。 + +## 8. P0 / P1 / P2 范围 + +### P0(11 issues — 当前里程碑) + +| Issue | 页面 | 核心功能 | +|-------|------|---------| +| ADMIN-INFO-000 | — | 总体设计(本文档) | +| ADMIN-INFO-001 | Dashboard | 全局统计、时间范围、knowledgeBaseId 筛选 | +| ADMIN-INFO-002 | ReadingEvent 列表/详情 | 分页、筛选(userId/materialId/eventType/status/时间) | +| ADMIN-INFO-003 | 异常事件视图 | failed/warning/duplicate 列表 + 筛选 | +| ADMIN-INFO-004 | 重放/修复 | 单事件重放、批量操作 | +| ADMIN-INFO-005 | Session 列表/详情 | 分页、按用户/资料/状态筛选 | +| ADMIN-INFO-006 | MaterialProgress 列表 | 分页、按用户/资料筛选 | +| ADMIN-INFO-007 | DailyActivity 热力图 | 日历视图、按用户/日期筛选 | +| ADMIN-INFO-008 | LearningRecord 列表 | 分页、按类型/用户筛选 | +| ADMIN-INFO-009 | 用户时间线 | 单用户活动时间线 | +| ADMIN-INFO-013 | interrupted session | active/interrupted session 列表 + 操作 | + +### P1(本里程碑之后) + +- 单用户学习数据诊断页 +- 单资料学习数据诊断页 +- 异常数据筛选与跨表诊断 +- 学习数据重算工具 +- 临时文件阅读数据管理(TemporaryReadingMaterial — 对应 Issue §11,API 端点待 API-ADMIN-INFO-014 定义) +- 学习事件处理配置 +- 学习数据导出 +- 权限与审计日志 + +### P2(最后) + +- Admin 页面/接口测试 +- 后台使用文档 +- 全局搜索 + +## 9. 通用页面模式 + +每个 P0 列表页统一使用 Ant Design ProTable: + +```tsx +// 通用模式 + + columns={columns} + request={async (params, sort, filter) => { + const { data, total } = await api.getXxx({ ...params, ...filter }); + return { data, success: true, total }; + }} + rowKey="id" + search={{ labelWidth: 'auto' }} + pagination={{ defaultPageSize: 20 }} + dateFormatter="string" +/> +``` + +详情页统一使用 Ant Design Descriptions + 关联数据子表。 + +## 10. 筛选器公共组件 + +``` +src/components/learning/ +├── EventStatusFilter.tsx # ReadingEvent status 筛选 +├── EventTypeFilter.tsx # eventType 筛选 +├── DateRangeFilter.tsx # 时间范围筛选 +├── UserMaterialFilter.tsx # userId / materialId 筛选 +└── KnowledgeBaseFilter.tsx # knowledgeBaseId 筛选 +``` + +## 11. 依赖说明 + +- **阻塞项**: 所有 P0 Issue 标记 `blocked-by:api-admin-info`,API 接口就绪后才能对接 +- **开发策略**: 先搭建页面框架 + TypeScript 类型 + ProTable 列定义,使用 `mock-data.ts` 做开发占位,API 就绪后切换 `learningAdmin.ts` 的真实调用 +- **不使用 mock 验收**: mock 仅限 `npm run dev` 开发阶段,CR 和上线必须以真实 API 数据为准 + +## 12. 路由注册 + +在 `src/routes/index.tsx` 中注册学习信息路由组: + +```tsx +{ + path: '/admin/learning', + element: , + children: [ + { index: true, element: }, + { path: 'events', element: }, + { path: 'events/:eventId', element: }, + { path: 'events/failed', element: }, + { path: 'sessions', element: }, + { path: 'sessions/:sessionId', element: }, + { path: 'progress', element: }, + { path: 'activity', element: }, + { path: 'records', element: }, + ] +} +``` + +## 13. 开发顺序 + +000(本文档)→ 搭建路由框架 + 类型定义 → 001 Dashboard → 002 Events → 003 Failed → 004 Replay → 005 Sessions → 006 Progress → 007 Activity → 008 Records → 009 Timeline → 013 Interrupted + +## 14. 验收清单 + +- [x] 输出 docs/admin-learning-info-ops-design.md +- [x] 明确本里程碑只做 Admin 前端 +- [x] 明确依赖 API-ADMIN-INFO +- [x] 明确不使用 mock 作为最终验收 +- [x] 明确页面路由和 tab 结构 +- [x] 明确每个页面对应的 API +- [x] 明确 P0/P1/P2 范围 diff --git a/src/pages/learning/Dashboard.tsx b/src/pages/learning/Dashboard.tsx index 0e6f080..9ee5de8 100644 --- a/src/pages/learning/Dashboard.tsx +++ b/src/pages/learning/Dashboard.tsx @@ -1,37 +1,111 @@ -import { Card, Col, Row, Statistic, Spin, Alert } from 'antd'; -import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined } from '@ant-design/icons'; +import { useState } from 'react'; +import { Card, Col, Row, Statistic, Spin, Alert, Select, DatePicker, Table, Tag, Space, Empty } from 'antd'; +import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined, FireOutlined, ReloadOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; +import dayjs from 'dayjs'; import { learningAdminAPI } from '../../services/learningAdmin'; +const { RangePicker } = DatePicker; + export default function DashboardPage() { - const { data, isLoading, error } = useQuery({ - queryKey: ['admin-dashboard'], - queryFn: () => learningAdminAPI.getDashboard(), + const [kbId, setKbId] = useState(undefined); + const [dateRange, setDateRange] = useState<[string, string] | undefined>(undefined); + + const { data: kbList = [] } = useQuery({ + queryKey: ['admin-kb-list'], + queryFn: () => learningAdminAPI.getKnowledgeBases(), + staleTime: 5 * 60 * 1000, + }); + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['admin-dashboard', kbId, dateRange], + queryFn: () => learningAdminAPI.getDashboard({ + knowledgeBaseId: kbId, + startDate: dateRange?.[0], + endDate: dateRange?.[1], + }), }); if (isLoading) return ; - if (error) return ; + if (error) return refetch()}>重试} />; + if (!data) return ; + + const d = data; + const anomalyColumns = [ + { title: '事件 ID', dataIndex: 'eventId', key: 'eventId', ellipsis: true, width: 180 }, + { title: '用户', dataIndex: 'userId', key: 'userId', ellipsis: true, width: 120 }, + { title: '资料', dataIndex: 'materialId', key: 'materialId', ellipsis: true, width: 120 }, + { title: '类型', dataIndex: 'eventType', key: 'eventType', width: 110, + render: (t: string) => {t} }, + { title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140, + render: (c?: string) => c ? {c} : '-' }, + { title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160, + render: (t: string) => dayjs(t).format('MM-DD HH:mm:ss') }, + ]; - const d = data!; return (
-

学习信息 Dashboard

+ +

学习信息 Dashboard

+ + setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 150 }} /> - setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 150 }} /> - 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: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} /> + { + if (dates && 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: () => 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 || '-'} + + )} + ); } @@ -46,23 +93,73 @@ export function ReadingEventPage() { 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], - queryFn: () => learningAdminAPI.getSessions({ page, limit: 20 }), + 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: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, - { title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true }, - { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s} }, - { title: '活跃秒数', dataIndex: 'totalActiveSeconds', width: 100 }, - { title: '开始', dataIndex: 'startedAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, + { 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', width: 90, ellipsis: true }, + { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 90, ellipsis: true }, + { title: '类型', dataIndex: 'targetType', key: 'targetType', width: 80, render: (v?: string) => v ? {v} : '-' }, + { title: '模式', dataIndex: 'mode', key: 'mode', width: 70, render: (v?: string) => v || '-' }, + { title: 'KB', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 80, ellipsis: true, render: (v?: string) => v?.slice(0, 8) || '-' }, + { title: '状态', dataIndex: 'status', key: 'status', width: 90, + render: (s: string) => {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 }} /> +
({ 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 || '-'} + + )} + ); } @@ -71,24 +168,66 @@ export function SessionPage() { 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], - queryFn: () => learningAdminAPI.getProgress({ page, limit: 20 }), + queryKey: ['admin-progress', page, filters], + queryFn: () => learningAdminAPI.getProgress({ page, limit: 20, ...filters }), }); const columns = [ - { title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, - { title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true }, - { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s} }, - { title: '进度', dataIndex: 'lastProgress', width: 80, render: (v: number) => v ? `${Math.round(v * 100)}%` : '-' }, - { title: '活跃秒', dataIndex: 'totalActiveSeconds', width: 80 }, - { title: '已读', dataIndex: 'isMarkedRead', width: 60, render: (v: boolean) => v ? '✅' : '' }, + { title: '用户', dataIndex: 'userId', key: 'userId', width: 90, ellipsis: true }, + { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 110, ellipsis: true }, + { title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => 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) => {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 }} /> +
({ 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 ?? '-'} +
+
+ +
+ + )} +
); } @@ -97,23 +236,56 @@ export function ProgressPage() { 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], - queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20 }), + queryKey: ['admin-daily', page, filters], + queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20, ...filters }), }); const columns = [ - { title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, - { title: '日期', dataIndex: 'activityDate', width: 120, render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' }, - { title: '阅读秒', dataIndex: 'readingSeconds', width: 80 }, - { title: '资料数', dataIndex: 'materialsReadCount', width: 70 }, - { title: '已读数', dataIndex: 'markedReadCount', width: 70 }, + { title: '用户', dataIndex: 'userId', key: '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 }} /> +
({ 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') : '-'} + + )} + ); } @@ -122,47 +294,118 @@ export function DailyActivityPage() { 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], - queryFn: () => learningAdminAPI.getRecords({ page, limit: 20 }), + queryKey: ['admin-records', page, filters], + queryFn: () => learningAdminAPI.getRecords({ page, limit: 20, ...filters }), }); const columns = [ - { title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, - { title: '标题', dataIndex: 'title', width: 200 }, - { title: '类型', dataIndex: 'recordType', width: 100, render: (t: string) => {t} }, - { title: '时长', dataIndex: 'durationSeconds', width: 80 }, - { title: '时间', dataIndex: 'occurredAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, + { title: '用户', dataIndex: 'userId', key: 'userId', width: 100, ellipsis: true }, + { title: '标题', dataIndex: 'title', key: 'title', width: 200 }, + { title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => {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 }} /> + + +
({ 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') : '-'} + + )} + ); } -// ── Anomalies ── +// ── 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'], - queryFn: () => learningAdminAPI.getAnomalies(), + queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter], + queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }), }); - const deltaCols = [ - { title: '事件ID', dataIndex: 'eventId', width: 200, ellipsis: true }, - { title: 'Delta', dataIndex: 'activeSecondsDelta', width: 80, render: (v: number) => {v} }, - { title: '时间', dataIndex: 'createdAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, + 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', width: 100, ellipsis: true }, + { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 100, ellipsis: true }, + { title: '事件类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => {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 ( - - 300)', children:
}, - { key: 'future', label: '未来时间戳', children:
}, - ]} /> + + + { 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') : '-'} + + )} + ); } diff --git a/src/pages/learning/ReplayPage.tsx b/src/pages/learning/ReplayPage.tsx new file mode 100644 index 0000000..05f35a4 --- /dev/null +++ b/src/pages/learning/ReplayPage.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { Card, Input, Button, Space, message, Modal, Form, DatePicker, Select, Descriptions, Alert, Tag } from 'antd'; +import { ThunderboltOutlined, WarningOutlined } from '@ant-design/icons'; +import { useMutation } from '@tanstack/react-query'; +import dayjs from 'dayjs'; +import { learningAdminAPI } from '../../services/learningAdmin'; + +const { RangePicker } = DatePicker; + +export default function ReplayPage() { + const [singleId, setSingleId] = useState(''); + const [batchForm] = Form.useForm(); + + const singleMutation = useMutation({ + mutationFn: (id: string) => learningAdminAPI.replayEvent(id), + onSuccess: (data) => message.success(data.message || '重放成功'), + onError: (e: Error) => message.error(`重放失败: ${e.message}`), + }); + + const batchMutation = useMutation({ + mutationFn: (params: Record) => learningAdminAPI.replayEventsBatch(params), + onSuccess: (data) => message.success(`完成: ${data.replayed} 成功, ${data.failed} 失败`), + onError: (e: Error) => message.error(`批量重放失败: ${e.message}`), + }); + + const handleSingleReplay = () => { + if (!singleId.trim()) { message.warning('请输入事件ID'); return; } + Modal.confirm({ + title: '确认重放', + icon: , + content: `确定要重放事件 ${singleId} 吗?此操作不会重复增加学习时长。`, + okText: '确认重放', + okType: 'primary', + cancelText: '取消', + onOk: () => singleMutation.mutate(singleId.trim()), + }); + }; + + const handleBatchReplay = () => { + const values = batchForm.getFieldsValue(); + const hasFilter = values.userId || values.materialId || values.eventType || (values.dateRange && values.dateRange[0]); + if (!hasFilter) { message.warning('请至少设置一个筛选条件'); return; } + const params: Record = {}; + if (values.userId) params.userId = values.userId; + if (values.materialId) params.materialId = values.materialId; + if (values.eventType) params.eventType = values.eventType; + if (values.dateRange?.[0]) { params.startDate = values.dateRange[0].format('YYYY-MM-DD'); params.endDate = values.dateRange[1].format('YYYY-MM-DD'); } + Modal.confirm({ + title: '确认批量重放', + icon: , + content: `将重放匹配条件的 failed 事件。此操作不可撤销,且不会重复增加学习时长。`, + okText: '确认批量重放', + okType: 'primary', + cancelText: '取消', + onOk: () => batchMutation.mutate(params), + }); + }; + + return ( +
+

事件重放

+ + + + + setSingleId(e.target.value)} + style={{ width: 320 }} + onPressEnter={handleSingleReplay} + allowClear + /> + + + {singleMutation.data && ( + + {singleMutation.data.success ? 成功 : 失败} + {singleMutation.data.message} + + )} + + + +
+ + + + + + + +