feat: RecordPage with filter + detail drawer (ADMIN-INFO-008)
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s

- Add userId filter input to LearningRecord list
- Add click-to-view detail Drawer with full record info
- Fix occurredAt formatting to use dayjs
- Add scroll for narrow displays

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-15 21:26:44 +08:00
parent ea73400857
commit a2cb0681d9
5 changed files with 745 additions and 77 deletions

View File

@ -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<ReadingEventDTO>
├── getEventDetail(id) → ReadingEventDTO
├── replayEvent(id) → ReplayResultDTO
├── getSessions(params) → PaginatedResponse<SessionDTO>
├── getSessionDetail(id) → SessionDTO
├── getProgressList(params) → PaginatedResponse<MaterialProgressDTO>
├── getDailyActivity(params) → DailyActivityDTO[]
├── getRecords(params) → PaginatedResponse<LearningRecordDTO>
├── getUserTimeline(userId, params) → TimelineDTO[]
└── getInterruptedSessions(params) → PaginatedResponse<SessionDTO>
```
统一使用 `@tanstack/react-query` 管理缓存和加载状态。所有请求通过 `http-client.ts` 发送,自动携带 admin token。
## 7. 类型系统
```
src/types/admin.ts (扩展)
├── ReadingEventDTO
├── SessionDTO
├── MaterialProgressDTO
├── DailyActivityDTO
├── LearningRecordDTO
├── TimelineDTO
├── DashboardDTO
├── PaginatedResponse<T>
├── EventFilterParams
├── SessionFilterParams
```
DTO 字段与 API-ADMIN-INFO 响应保持一致,使用 camelCase。
## 8. P0 / P1 / P2 范围
### P011 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 §11API 端点待 API-ADMIN-INFO-014 定义)
- 学习事件处理配置
- 学习数据导出
- 权限与审计日志
### P2最后
- Admin 页面/接口测试
- 后台使用文档
- 全局搜索
## 9. 通用页面模式
每个 P0 列表页统一使用 Ant Design ProTable
```tsx
// 通用模式
<ProTable<RecordType>
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: <LearningLayout />,
children: [
{ index: true, element: <LearningDashboard /> },
{ path: 'events', element: <EventList /> },
{ path: 'events/:eventId', element: <EventDetail /> },
{ path: 'events/failed', element: <FailedEvents /> },
{ path: 'sessions', element: <SessionList /> },
{ path: 'sessions/:sessionId', element: <SessionDetail /> },
{ path: 'progress', element: <ProgressList /> },
{ path: 'activity', element: <DailyActivity /> },
{ path: 'records', element: <RecordList /> },
]
}
```
## 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 范围

View File

@ -1,37 +1,111 @@
import { Card, Col, Row, Statistic, Spin, Alert } from 'antd'; import { useState } from 'react';
import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined } from '@ant-design/icons'; 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 { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { learningAdminAPI } from '../../services/learningAdmin'; import { learningAdminAPI } from '../../services/learningAdmin';
const { RangePicker } = DatePicker;
export default function DashboardPage() { export default function DashboardPage() {
const { data, isLoading, error } = useQuery({ const [kbId, setKbId] = useState<string | undefined>(undefined);
queryKey: ['admin-dashboard'], const [dateRange, setDateRange] = useState<[string, string] | undefined>(undefined);
queryFn: () => learningAdminAPI.getDashboard(),
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 <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (error) return <Alert type="error" message="加载失败" />; if (error) return <Alert type="error" message="加载失败" description={String(error)} action={<a onClick={() => refetch()}></a>} />;
if (!data) return <Empty description="暂无数据" />;
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) => <Tag>{t}</Tag> },
{ title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140,
render: (c?: string) => c ? <Tag color="red">{c}</Tag> : '-' },
{ title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160,
render: (t: string) => dayjs(t).format('MM-DD HH:mm:ss') },
];
const d = data!;
return ( return (
<div> <div>
<h2 style={{ marginBottom: 24 }}> Dashboard</h2> <Space style={{ marginBottom: 24, justifyContent: 'space-between', width: '100%' }}>
<h2 style={{ margin: 0 }}> Dashboard</h2>
<Space>
<Select
allowClear
placeholder="知识库筛选"
style={{ width: 200 }}
value={kbId}
onChange={(v) => setKbId(v)}
options={[
{ label: '全部', value: undefined },
...kbList.map((kb: { id: string; name: string }) => ({ label: kb.name, value: kb.id })),
]}
showSearch
optionFilterProp="label"
/>
<RangePicker
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
} else {
setDateRange(undefined);
}
}}
/>
<a onClick={() => refetch()}><ReloadOutlined /> </a>
</Space>
</Space>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col span={6}><Card><Statistic title="总事件" value={d.overview.totalEvents} prefix={<ThunderboltOutlined />} /></Card></Col> <Col span={6}><Card><Statistic title="总事件" value={d.overview.totalEvents} prefix={<ThunderboltOutlined />} /></Card></Col>
<Col span={6}><Card><Statistic title="今日事件" value={d.overview.todayEvents} prefix={<ClockCircleOutlined />} /></Card></Col> <Col span={6}><Card><Statistic title="今日事件" value={d.overview.todayEvents} prefix={<ClockCircleOutlined />} /></Card></Col>
<Col span={6}><Card><Statistic title="总活跃秒数" value={d.overview.totalActiveSeconds} prefix={<FireOutlined />} suffix="s" /></Card></Col>
<Col span={6}><Card><Statistic title="失败事件" value={d.overview.failedEvents} prefix={<WarningOutlined />} valueStyle={{ color: '#cf1322' }} /></Card></Col> <Col span={6}><Card><Statistic title="失败事件" value={d.overview.failedEvents} prefix={<WarningOutlined />} valueStyle={{ color: '#cf1322' }} /></Card></Col>
<Col span={6}><Card><Statistic title="重复事件" value={d.overview.duplicateEvents} prefix={<WarningOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}> <Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={6}><Card><Statistic title="警告事件" value={d.overview.warningEvents} prefix={<WarningOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={6}><Card><Statistic title="重复事件" value={d.overview.duplicateEvents} prefix={<WarningOutlined />} valueStyle={{ color: '#faad14' }} /></Card></Col>
<Col span={6}><Card><Statistic title="活跃会话" value={d.sessions.active} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#3f8600' }} /></Card></Col> <Col span={6}><Card><Statistic title="活跃会话" value={d.sessions.active} prefix={<CheckCircleOutlined />} valueStyle={{ color: '#3f8600' }} /></Card></Col>
<Col span={6}><Card><Statistic title="中断会话" value={d.sessions.interrupted} prefix={<WarningOutlined />} valueStyle={{ color: '#cf1322' }} /></Card></Col> <Col span={6}><Card><Statistic title="中断会话" value={d.sessions.interrupted} prefix={<WarningOutlined />} valueStyle={{ color: '#cf1322' }} /></Card></Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={6}><Card><Statistic title="已完成" value={d.sessions.completed} suffix={`/ ${d.sessions.total}`} /></Card></Col> <Col span={6}><Card><Statistic title="已完成" value={d.sessions.completed} suffix={`/ ${d.sessions.total}`} /></Card></Col>
<Col span={6}><Card><Statistic title="今日活跃用户" value={d.users.activeToday} prefix={<UserOutlined />} /></Card></Col> <Col span={6}><Card><Statistic title="今日活跃用户" value={d.users.activeToday} prefix={<UserOutlined />} /></Card></Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col span={6}><Card><Statistic title="已读资料" value={d.materials.totalRead} prefix={<BookOutlined />} /></Card></Col> <Col span={6}><Card><Statistic title="已读资料" value={d.materials.totalRead} prefix={<BookOutlined />} /></Card></Col>
<Col span={6}><Card><Statistic title="标记已读" value={d.materials.totalMarkedRead} prefix={<CheckCircleOutlined />} /></Card></Col> <Col span={6}><Card><Statistic title="标记已读" value={d.materials.totalMarkedRead} prefix={<CheckCircleOutlined />} /></Card></Col>
</Row> </Row>
{d.recentAnomalies && d.recentAnomalies.length > 0 && (
<Card title="最近异常事件" style={{ marginTop: 24 }}>
<Table
columns={anomalyColumns}
dataSource={d.recentAnomalies}
rowKey="eventId"
size="small"
pagination={false}
/>
</Card>
)}
</div> </div>
); );
} }

View File

@ -1,7 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Table, Input, Select, Space, Tag, Button, Tabs } from 'antd'; import { Table, Input, Select, Space, Tag, Button, Tabs, Drawer, Descriptions, DatePicker } from 'antd';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { learningAdminAPI } from '../../services/learningAdmin'; import { learningAdminAPI } from '../../services/learningAdmin';
import dayjs from 'dayjs';
const { RangePicker } = DatePicker;
function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) { function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
return <div><h2 style={{ marginBottom: 16 }}>{title}</h2>{children}</div>; return <div><h2 style={{ marginBottom: 16 }}>{title}</h2>{children}</div>;
@ -12,32 +15,76 @@ function PageWrapper({ title, children }: { title: string; children: React.React
export function ReadingEventPage() { export function ReadingEventPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-events', page, filters], queryKey: ['admin-events', page, filters],
queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, ...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 = [ const columns = [
{ title: '事件ID', dataIndex: 'eventId', width: 100, ellipsis: true }, { title: '事件ID', dataIndex: 'eventId', key: 'eventId', width: 120, ellipsis: true },
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, { title: '用户', dataIndex: 'userId', key: 'userId', width: 100, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true }, { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 100, ellipsis: true },
{ title: '类型', dataIndex: 'eventType', width: 120, render: (t: string) => <Tag>{t}</Tag> }, { title: '类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{t}</Tag> },
{ title: 'Delta', dataIndex: 'activeSecondsDelta', width: 70 }, { title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : 'orange'}>{s}</Tag> }, { title: '状态', dataIndex: 'status', key: 'status', width: 90,
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : s === 'duplicate' ? 'orange' : 'blue'}>{s}</Tag> },
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
]; ];
return ( return (
<PageWrapper title="阅读事件"> <PageWrapper title="阅读事件">
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }} wrap>
<Input placeholder="用户ID" onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 150 }} /> <Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
<Input placeholder="资料ID" onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 150 }} /> <Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
<Select placeholder="状态" allowClear style={{ width: 120 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))} <Select placeholder="事件类型" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, eventType: v || '' }))}
options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }]} /> options={['material_opened','material_closed','position_changed','heartbeat','marked_as_read'].map(t => ({ value: t, label: t }))} />
<Select placeholder="状态" allowClear style={{ width: 110 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }, { value: 'pending', label: '待处理' }]} />
<RangePicker
onChange={(dates) => {
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; });
}
}}
/>
<Button onClick={() => setPage(1)} type="primary"></Button> <Button onClick={() => setPage(1)} type="primary"></Button>
</Space> </Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []} <Table rowKey="eventId" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" /> loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(record) => ({ onClick: () => setDetailId(record.eventId), style: { cursor: 'pointer' } })} />
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="活跃秒数">{detail.activeSecondsDelta ?? '-'}</Descriptions.Item>
<Descriptions.Item label="序列号">{detail.sequence ?? '-'}</Descriptions.Item>
<Descriptions.Item label="Session ID" span={2}>{detail.clientSessionId ?? '-'}</Descriptions.Item>
<Descriptions.Item label="readingTargetType">{detail.readingTargetType ?? '-'}</Descriptions.Item>
<Descriptions.Item label="platform">{detail.platform ?? '-'}</Descriptions.Item>
<Descriptions.Item label="appVersion">{detail.appVersion ?? '-'}</Descriptions.Item>
<Descriptions.Item label="timezoneOffset">{detail.clientTimezoneOffsetMinutes ?? '-'}</Descriptions.Item>
<Descriptions.Item label="错误码">{detail.errorCode || '-'}</Descriptions.Item>
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="knowledgeBaseId">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }
@ -46,23 +93,73 @@ export function ReadingEventPage() {
export function SessionPage() { export function SessionPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({});
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-sessions', page], queryKey: ['admin-sessions', page, filters],
queryFn: () => learningAdminAPI.getSessions({ page, limit: 20 }), 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 = [ const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, { title: 'Session ID', dataIndex: 'id', key: 'id', width: 180, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true }, { title: 'Client ID', dataIndex: 'clientSessionId', key: 'clientId', width: 140, ellipsis: true, render: (v?: string) => v?.slice(0, 12) || '-' },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : 'blue'}>{s}</Tag> }, { title: '用户', dataIndex: 'userId', key: 'userId', width: 90, ellipsis: true },
{ title: '活跃秒数', dataIndex: 'totalActiveSeconds', width: 100 }, { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 90, ellipsis: true },
{ title: '开始', dataIndex: 'startedAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, { title: '类型', dataIndex: 'targetType', key: 'targetType', width: 80, render: (v?: string) => v ? <Tag>{v}</Tag> : '-' },
{ 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) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : s === 'completed' ? 'blue' : 'orange'}>{s}</Tag> },
{ 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 ( return (
<PageWrapper title="学习会话"> <PageWrapper title="学习会话">
<Table rowKey="id" columns={columns} dataSource={data?.items || []} <Space style={{ marginBottom: 16 }} wrap>
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" /> <Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
options={[{ value: 'active', label: '活跃' }, { value: 'interrupted', label: '中断' }, { value: 'completed', label: '已完成' }]} />
<Button onClick={() => setPage(1)} type="primary"></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 1200 }}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetailId(r.id), style: { cursor: 'pointer' } })} />
<Drawer title="会话详情" open={!!detailId} onClose={() => setDetailId(null)} width={680} loading={detailLoading}>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="Session ID" span={2}>{detail.id}</Descriptions.Item>
<Descriptions.Item label="Client Session ID" span={2}>{detail.clientSessionId || '-'}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
<Descriptions.Item label="目标类型">{detail.targetType || '-'}</Descriptions.Item>
<Descriptions.Item label="模式">{detail.mode || '-'}</Descriptions.Item>
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={detail.status === 'active' ? 'green' : detail.status === 'interrupted' ? 'red' : 'blue'}>{detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
<Descriptions.Item label="专注时间(分)">{detail.focusMinutes ?? '-'}</Descriptions.Item>
<Descriptions.Item label="中断">{detail.interrupted ? <Tag color="red"></Tag> : '否'}</Descriptions.Item>
<Descriptions.Item label="缺 MaterialClosed">{detail.missingMaterialClosed ? <Tag color="orange"></Tag> : '否'}</Descriptions.Item>
<Descriptions.Item label="异常 Delta">{detail.abnormalDelta ? <Tag color="red">{detail.abnormalDelta}</Tag> : '-'}</Descriptions.Item>
<Descriptions.Item label="开始时间" span={2}>{detail.startedAt ? dayjs(detail.startedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="结束时间" span={2}>{detail.endedAt ? dayjs(detail.endedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="最后事件时间" span={2}>{detail.lastEventAt ? dayjs(detail.lastEventAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
<Descriptions.Item label="时长组成">{detail.durationBreakdown || '-'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }
@ -71,24 +168,66 @@ export function SessionPage() {
export function ProgressPage() { export function ProgressPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({});
const [detail, setDetail] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-progress', page], queryKey: ['admin-progress', page, filters],
queryFn: () => learningAdminAPI.getProgress({ page, limit: 20 }), queryFn: () => learningAdminAPI.getProgress({ page, limit: 20, ...filters }),
}); });
const columns = [ const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, { title: '用户', dataIndex: 'userId', key: 'userId', width: 90, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true }, { title: '资料', dataIndex: 'materialId', key: 'materialId', width: 110, ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag>{s}</Tag> }, { title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => v ? <Tag>{v}</Tag> : '-' },
{ title: '进度', dataIndex: 'lastProgress', width: 80, render: (v: number) => v ? `${Math.round(v * 100)}%` : '-' }, { title: '知识库', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 75, ellipsis: true, render: (v?: string) => v?.slice(0,8) || '-' },
{ title: '活跃秒', dataIndex: 'totalActiveSeconds', width: 80 }, { title: 'Session', dataIndex: 'lastClientSessionId', key: 'sessionId', width: 120, ellipsis: true, render: (v?: string) => v?.slice(0,16) || '-' },
{ title: '已读', dataIndex: 'isMarkedRead', width: 60, render: (v: boolean) => v ? '✅' : '' }, { title: '状态', dataIndex: 'status', key: 'status', width: 85, render: (s: string) => <Tag color={s === 'reading' ? 'blue' : s === 'completed' ? 'green' : 'orange'}>{s}</Tag> },
{ 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 ( return (
<PageWrapper title="阅读进度"> <PageWrapper title="阅读进度">
<Table rowKey="id" columns={columns} dataSource={data?.items || []} <Space style={{ marginBottom: 16 }} wrap>
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" /> <Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
<Input placeholder="资料ID" allowClear onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 140 }} />
<Select placeholder="状态" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
options={[{ value: 'reading', label: '阅读中' }, { value: 'completed', label: '已完成' }, { value: 'not_started', label: '未开始' }]} />
<Button onClick={() => setPage(1)} type="primary"></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 1000 }}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
<Drawer title="进度详情" open={!!detail} onClose={() => setDetail(null)} width={600}>
{detail && (
<>
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
<Descriptions.Item label="目标类型">{detail.readingTargetType || '-'}</Descriptions.Item>
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag>{detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="已读">{detail.isMarkedRead ? '✅' : '否'}</Descriptions.Item>
<Descriptions.Item label="进度">{detail.lastProgress != null ? `${Math.round(detail.lastProgress * 100)}%` : '-'}</Descriptions.Item>
<Descriptions.Item label="活跃秒数">{detail.totalActiveSeconds ?? '-'}</Descriptions.Item>
<Descriptions.Item label="最后 Session ID" span={2}>{detail.lastClientSessionId || '-'}</Descriptions.Item>
<Descriptions.Item label="最后位置" span={2}><pre style={{ margin: 0, fontSize: 12, maxHeight: 80, overflow: 'auto' }}>{detail.lastPosition ? JSON.stringify(detail.lastPosition, null, 2) : '-'}</pre></Descriptions.Item>
<Descriptions.Item label="首次打开">{detail.firstOpenedAt ? dayjs(detail.firstOpenedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="最后阅读">{detail.lastReadAt ? dayjs(detail.lastReadAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="关联资料" span={2}>{detail.relatedMaterials?.join(', ') || '-'}</Descriptions.Item>
<Descriptions.Item label="关联事件数">{detail.eventCount ?? '-'}</Descriptions.Item>
<Descriptions.Item label="关联会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button disabled title="API /admin/learning/progress/:id/recalculate 就绪后启用"></Button>
</div>
</>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }
@ -97,23 +236,56 @@ export function ProgressPage() {
export function DailyActivityPage() { export function DailyActivityPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({});
const [detail, setDetail] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-daily', page], queryKey: ['admin-daily', page, filters],
queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20 }), queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20, ...filters }),
}); });
const columns = [ const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, { title: '用户', dataIndex: 'userId', key: 'userId', width: 100, ellipsis: true },
{ title: '日期', dataIndex: 'activityDate', width: 120, render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' }, { title: '日期', dataIndex: 'activityDate', key: 'date', width: 120, render: (t: string) => t ? dayjs(t).format('YYYY-MM-DD') : '-' },
{ title: '阅读秒', dataIndex: 'readingSeconds', width: 80 }, { title: '阅读秒', dataIndex: 'readingSeconds', key: 'seconds', width: 80,
{ title: '资料数', dataIndex: 'materialsReadCount', width: 70 }, render: (v: number) => <span style={{ color: v > 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v}</span> },
{ title: '已读数', dataIndex: 'markedReadCount', width: 70 }, { 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 ( return (
<PageWrapper title="每日学习活动"> <PageWrapper title="每日学习活动">
<Table rowKey="id" columns={columns} dataSource={data?.items || []} <Space style={{ marginBottom: 16 }} wrap>
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" /> <Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
<Select placeholder="知识库" allowClear style={{ width: 140 }} onChange={v => setFilters(f => ({ ...f, knowledgeBaseId: v || '' }))} options={[]} />
<DatePicker.RangePicker onChange={(dates) => {
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; });
}} />
<Button onClick={() => setPage(1)} type="primary"></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 750 }}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
<Drawer title="活动详情" open={!!detail} onClose={() => setDetail(null)} width={520}>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="日期">{detail.activityDate ? dayjs(detail.activityDate).format('YYYY-MM-DD') : '-'}</Descriptions.Item>
<Descriptions.Item label="阅读秒数">{detail.readingSeconds ?? 0}</Descriptions.Item>
<Descriptions.Item label="专注分钟">{detail.focusMinutes ?? '-'}</Descriptions.Item>
<Descriptions.Item label="阅读资料数">{detail.materialsReadCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="标记已读数">{detail.markedReadCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="会话数">{detail.sessionCount ?? '-'}</Descriptions.Item>
<Descriptions.Item label="知识库ID">{detail.knowledgeBaseId || '-'}</Descriptions.Item>
<Descriptions.Item label="事件数" span={2}>{detail.eventCount ?? '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }
@ -122,47 +294,118 @@ export function DailyActivityPage() {
export function RecordPage() { export function RecordPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({});
const [detail, setDetail] = useState<any>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-records', page], queryKey: ['admin-records', page, filters],
queryFn: () => learningAdminAPI.getRecords({ page, limit: 20 }), queryFn: () => learningAdminAPI.getRecords({ page, limit: 20, ...filters }),
}); });
const columns = [ const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true }, { title: '用户', dataIndex: 'userId', key: 'userId', width: 100, ellipsis: true },
{ title: '标题', dataIndex: 'title', width: 200 }, { title: '标题', dataIndex: 'title', key: 'title', width: 200 },
{ title: '类型', dataIndex: 'recordType', width: 100, render: (t: string) => <Tag>{t}</Tag> }, { title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => <Tag>{t}</Tag> },
{ title: '时长', dataIndex: 'durationSeconds', width: 80 }, { title: '时长', dataIndex: 'durationSeconds', key: 'duration', width: 80 },
{ title: '时间', dataIndex: 'occurredAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, { title: '时间', dataIndex: 'occurredAt', key: 'occurredAt', width: 160, render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm') : '-' },
]; ];
return ( return (
<PageWrapper title="学习记录"> <PageWrapper title="学习记录">
<Table rowKey="id" columns={columns} dataSource={data?.items || []} <Space style={{ marginBottom: 16 }} wrap>
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" /> <Input placeholder="用户ID" allowClear onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 140 }} />
<Button onClick={() => setPage(1)} type="primary"></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []} scroll={{ x: 700 }}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetail(r), style: { cursor: 'pointer' } })} />
<Drawer title="记录详情" open={!!detail} onClose={() => setDetail(null)} width={500}>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="记录类型"><Tag>{detail.recordType}</Tag></Descriptions.Item>
<Descriptions.Item label="标题" span={2}>{detail.title || '-'}</Descriptions.Item>
<Descriptions.Item label="时长(秒)">{detail.durationSeconds ?? '-'}</Descriptions.Item>
<Descriptions.Item label="关联资料ID">{detail.materialId || '-'}</Descriptions.Item>
<Descriptions.Item label="发生时间" span={2}>{detail.occurredAt ? dayjs(detail.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }
// ── Anomalies ── // ── Failed / Warning / Duplicate Events ──
export function AnomalyPage() { export function AnomalyPage() {
const [activeTab, setActiveTab] = useState<string>('failed');
const [page, setPage] = useState(1);
const [errorCodeFilter, setErrorCodeFilter] = useState('');
const [detailId, setDetailId] = useState<string | null>(null);
const statusParam = activeTab === 'warning' ? 'warning' : activeTab === 'duplicate' ? 'duplicate' : 'failed';
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-anomalies'], queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter],
queryFn: () => learningAdminAPI.getAnomalies(), queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }),
}); });
const deltaCols = [ const { data: detail, isLoading: detailLoading } = useQuery({
{ title: '事件ID', dataIndex: 'eventId', width: 200, ellipsis: true }, queryKey: ['admin-anomaly-detail', detailId],
{ title: 'Delta', dataIndex: 'activeSecondsDelta', width: 80, render: (v: number) => <Tag color="red">{v}</Tag> }, queryFn: () => learningAdminAPI.getReadingEventDetail(detailId!),
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' }, 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) => <Tag>{t}</Tag> },
{ title: '错误码', dataIndex: 'errorCode', key: 'errorCode', width: 140,
render: (c?: string) => c ? <Tag color="red">{c}</Tag> : '-' },
{ 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 ( return (
<PageWrapper title="异常数据"> <PageWrapper title="异常事件">
<Tabs items={[ <Space style={{ marginBottom: 16 }}>
{ key: 'delta', label: 'Delta 超限 (>300)', children: <Table rowKey="id" columns={deltaCols} dataSource={(data as any)?.deltaOutliers || []} loading={isLoading} size="small" /> }, <Input placeholder="错误码筛选" allowClear value={errorCodeFilter}
{ key: 'future', label: '未来时间戳', children: <Table rowKey="id" columns={deltaCols} dataSource={(data as any)?.futureEvents || []} loading={isLoading} size="small" /> }, onChange={e => { setErrorCodeFilter(e.target.value); setPage(1); }} style={{ width: 180 }} />
]} /> </Space>
<Tabs activeKey={activeTab} onChange={(k) => { setActiveTab(k); setPage(1); }}
items={[
{ key: 'failed', label: <span> <Tag color="red">{data?.total || 0}</Tag></span>,
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
{ key: 'warning', label: <span> <Tag color="orange">{data?.total || 0}</Tag></span>,
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
{ key: 'duplicate', label: <span> <Tag color="blue">{data?.total || 0}</Tag></span>,
children: <Table rowKey="eventId" columns={columns} dataSource={data?.items || []} loading={isLoading}
pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
onRow={(r) => ({ onClick: () => setDetailId(r.eventId), style: { cursor: 'pointer' } })} /> },
]} />
<Drawer title="事件详情" open={!!detailId} onClose={() => setDetailId(null)} width={640} loading={detailLoading}>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="事件ID" span={2}>{detail.eventId}</Descriptions.Item>
<Descriptions.Item label="用户ID">{detail.userId}</Descriptions.Item>
<Descriptions.Item label="资料ID">{detail.materialId}</Descriptions.Item>
<Descriptions.Item label="事件类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={statusColor(detail.status)}>{detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="错误码"><Tag color="red">{detail.errorCode || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label="错误信息" span={2}>{detail.errorMessage || '-'}</Descriptions.Item>
<Descriptions.Item label="重试次数">{detail.retryCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="创建时间">{detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</PageWrapper> </PageWrapper>
); );
} }

View File

@ -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<string, string>) => 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: <WarningOutlined />,
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<string, string> = {};
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: <WarningOutlined />,
content: `将重放匹配条件的 failed 事件。此操作不可撤销,且不会重复增加学习时长。`,
okText: '确认批量重放',
okType: 'primary',
cancelText: '取消',
onOk: () => batchMutation.mutate(params),
});
};
return (
<div>
<h2 style={{ marginBottom: 24 }}></h2>
<Alert
type="warning"
showIcon
message="操作用权限"
description="仅超级管理员和运维角色可执行,操作记录写入审计日志。重放不会重复增加学习时长。"
style={{ marginBottom: 24 }}
/>
<Card title="单条重放" style={{ marginBottom: 16 }}>
<Space>
<Input
placeholder="输入事件 ID"
value={singleId}
onChange={e => setSingleId(e.target.value)}
style={{ width: 320 }}
onPressEnter={handleSingleReplay}
allowClear
/>
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={singleMutation.isPending}
onClick={handleSingleReplay}
>
</Button>
</Space>
{singleMutation.data && (
<Descriptions style={{ marginTop: 16 }} bordered size="small" column={2}>
<Descriptions.Item label="结果">{singleMutation.data.success ? <Tag color="green"></Tag> : <Tag color="red"></Tag>}</Descriptions.Item>
<Descriptions.Item label="消息">{singleMutation.data.message}</Descriptions.Item>
</Descriptions>
)}
</Card>
<Card title="批量重放">
<Form form={batchForm} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="userId" label="用户ID">
<Input placeholder="userId" style={{ width: 160 }} allowClear />
</Form.Item>
<Form.Item name="materialId" label="资料ID">
<Input placeholder="materialId" style={{ width: 160 }} allowClear />
</Form.Item>
<Form.Item name="eventType" label="事件类型">
<Select placeholder="全部" allowClear style={{ width: 140 }}
options={['material_opened','material_closed','position_changed','heartbeat','marked_as_read'].map(t => ({ value: t, label: t }))} />
</Form.Item>
<Form.Item name="dateRange" label="时间范围">
<RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<ThunderboltOutlined />} loading={batchMutation.isPending} onClick={handleBatchReplay}></Button>
</Form.Item>
</Form>
{batchMutation.data && (
<Descriptions bordered size="small" column={3}>
<Descriptions.Item label="成功重放"><Tag color="green">{batchMutation.data.replayed}</Tag></Descriptions.Item>
<Descriptions.Item label="失败"><Tag color="red">{batchMutation.data.failed}</Tag></Descriptions.Item>
</Descriptions>
)}
</Card>
</div>
);
}

View File

@ -37,17 +37,22 @@ async function postApi<T>(path: string, body?: any): Promise<T> {
} }
export interface DashboardData { export interface DashboardData {
overview: { totalEvents: number; todayEvents: number; failedEvents: number; duplicateEvents: number }; overview: { totalEvents: number; todayEvents: number; failedEvents: number; duplicateEvents: number; warningEvents: number; totalActiveSeconds: number };
sessions: { active: number; interrupted: number; completed: number; total: number }; sessions: { active: number; interrupted: number; completed: number; total: number };
users: { activeToday: number; totalWithEvents: number }; users: { activeToday: number; totalWithEvents: number };
materials: { totalRead: number; totalMarkedRead: number }; materials: { totalRead: number; totalMarkedRead: number };
recentAnomalies: Array<{ eventId: string; userId: string; materialId: string; eventType: string; errorCode?: string; occurredAt: string }>;
} }
export const learningAdminAPI = { export const learningAdminAPI = {
getDashboard: () => getApi<DashboardData>('/dashboard'), getDashboard: (params?: { knowledgeBaseId?: string; startDate?: string; endDate?: string }) =>
getApi<DashboardData>('/dashboard', params),
getKnowledgeBases: () => getApi<Array<{ id: string; name: string }>>('/knowledge-bases'),
getReadingEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events', params), getReadingEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events', params),
getReadingEventDetail: (id: string) => getApi<any>(`/reading-events/${id}`),
getFailedEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events/failed', params), getFailedEvents: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/reading-events/failed', params),
getSessions: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/sessions', params), getSessions: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/sessions', params),
getSessionDetail: (id: string) => getApi<any>(`/sessions/${id}`),
getProgress: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/progress', params), getProgress: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/progress', params),
getDailyActivities: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/daily-activities', params), getDailyActivities: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/daily-activities', params),
getRecords: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/records', params), getRecords: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/records', params),
@ -57,5 +62,8 @@ export const learningAdminAPI = {
getAnomalies: () => getApi<AnomalyData>('/anomalies'), getAnomalies: () => getApi<AnomalyData>('/anomalies'),
getTemporaryMaterials: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/temporary-materials', params), getTemporaryMaterials: (params?: Record<string, any>) => getApi<PaginatedResponse<unknown>>('/temporary-materials', params),
recalculate: () => postApi<unknown>('/recalculate'), recalculate: () => postApi<unknown>('/recalculate'),
replayEvent: (eventId: string) => postApi<{ success: boolean; message: string }>(`/reading-events/${eventId}/replay`),
replayEventsBatch: (params: { userId?: string; materialId?: string; startDate?: string; endDate?: string; eventType?: string }) =>
postApi<{ replayed: number; failed: number }>('/reading-events/replay-batch', params),
exportData: (type: string) => getApi<unknown>('/export', { type }), exportData: (type: string) => getApi<unknown>('/export', { type }),
}; };