feat: ADMIN-INFO admin learning dashboard frontend (21/21)
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s

- Dashboard page with statistics cards
- Data pages: ReadingEvents, Sessions, Progress, DailyActivities, Records
- Diagnostic: Anomalies, User/Material Diagnose
- API service: learningAdmin.ts
- Routes: /learning/*

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-09 21:55:52 +08:00
parent 8144bdc42f
commit a9a7d651bb
5 changed files with 370 additions and 0 deletions

View File

@ -0,0 +1,70 @@
# 学习信息后台 总设计
> ADMIN-INFO-000 | v1.0 | 2026-06-09
## 1. 概述
基于 M8 + M-API-ADMIN-INFO 后端接口,构建学习信息管理后台的前端页面。
## 2. 技术栈
- React 19 + TypeScript
- Ant Design 5 (antd)
- @ant-design/pro-components (ProTable)
- React Router v6
- @tanstack/react-query
- ECharts (图表)
## 3. 路由结构
```
/admin/learning/dashboard — Dashboard
/admin/learning/reading-events — 阅读事件列表
/admin/learning/reading-events/:id — 事件详情
/admin/learning/sessions — 学习会话列表
/admin/learning/progress — 阅读进度列表
/admin/learning/daily-activities — 每日活动/热力图
/admin/learning/records — 学习记录列表
/admin/learning/anomalies — 异常数据
/admin/learning/user-diagnose — 用户诊断
/admin/learning/material-diagnose — 资料诊断
/admin/learning/temporary-materials — 临时文件
/admin/learning/export — 数据导出
```
## 4. 组件树
```
AdminLayout
├── DashboardPage — 统计卡片 + 图表
├── ReadingEventPage — ProTable + 筛选
│ └── EventDetailPage — 描述列表
├── SessionPage — ProTable
├── ProgressPage — ProTable
├── DailyActivityPage — 日历热力图
├── RecordPage — ProTable
├── AnomalyPage — 异常列表
├── UserDiagnosePage — 用户数据面板
├── MaterialDiagnosePage — 资料数据面板
├── TempMaterialPage — 临时文件管理
└── ExportPage — 导出表单
```
## 5. API 对接
所有接口 base: `/admin/learning/*`
| 页面 | API |
|------|-----|
| Dashboard | `GET /admin/learning/dashboard` |
| 事件列表 | `GET /admin/learning/reading-events` |
| 事件详情 | `GET /admin/learning/reading-events/:id` |
| 会话列表 | `GET /admin/learning/sessions` |
| 进度列表 | `GET /admin/learning/progress` |
| 每日活动 | `GET /admin/learning/daily-activities` |
| 记录列表 | `GET /admin/learning/records` |
| 用户诊断 | `GET /admin/learning/user-diagnose?userId=` |
| 资料诊断 | `GET /admin/learning/material-diagnose?materialId=` |
| 异常数据 | `GET /admin/learning/anomalies` |
| 重算 | `POST /admin/learning/recalculate` |
| 导出 | `GET /admin/learning/export` |

View File

@ -0,0 +1,37 @@
import { Card, Col, Row, Statistic, Spin, Alert } from 'antd';
import { BookOutlined, ThunderboltOutlined, UserOutlined, WarningOutlined, CheckCircleOutlined, ClockCircleOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { learningAdminAPI } from '../../services/learningAdmin';
export default function DashboardPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['admin-dashboard'],
queryFn: () => learningAdminAPI.getDashboard(),
});
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (error) return <Alert type="error" message="加载失败" />;
const d = data!;
return (
<div>
<h2 style={{ marginBottom: 24 }}> Dashboard</h2>
<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.todayEvents} prefix={<ClockCircleOutlined />} /></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 gutter={[16, 16]} style={{ marginTop: 16 }}>
<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.completed} suffix={`/ ${d.sessions.total}`} /></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.totalMarkedRead} prefix={<CheckCircleOutlined />} /></Card></Col>
</Row>
</div>
);
}

View File

@ -0,0 +1,218 @@
import { useState } from 'react';
import { Table, Card, Input, Select, Space, Tag, Descriptions, Button, message, Spin, Alert, Tabs } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { learningAdminAPI } from '../../services/learningAdmin';
function PageWrapper({ title, children }: { title: string; children: React.ReactNode }) {
return <div><h2 style={{ marginBottom: 16 }}>{title}</h2>{children}</div>;
}
// ── Reading Events ──
export function ReadingEventPage() {
const [page, setPage] = useState(1);
const [filters, setFilters] = useState<Record<string, string>>({});
const [detailId, setDetailId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-events', page, filters],
queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, ...filters }),
});
const { data: detail } = useQuery({
queryKey: ['admin-event-detail', detailId],
queryFn: () => learningAdminAPI.getReadingEvents({}).then(() => null),
enabled: false,
});
const columns = [
{ title: '事件ID', dataIndex: 'eventId', width: 100, ellipsis: true },
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true },
{ title: '类型', dataIndex: 'eventType', width: 120, render: (t: string) => <Tag>{t}</Tag> },
{ title: 'Delta', dataIndex: 'activeSecondsDelta', width: 70 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : 'orange'}>{s}</Tag> },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' },
];
return (
<PageWrapper title="阅读事件">
<Space style={{ marginBottom: 16 }}>
<Input placeholder="用户ID" onChange={e => setFilters(f => ({ ...f, userId: e.target.value }))} style={{ width: 150 }} />
<Input placeholder="资料ID" onChange={e => setFilters(f => ({ ...f, materialId: e.target.value }))} style={{ width: 150 }} />
<Select placeholder="状态" allowClear style={{ width: 120 }} onChange={v => setFilters(f => ({ ...f, status: v || '' }))}
options={[{ value: 'processed', label: '已处理' }, { value: 'failed', label: '失败' }, { value: 'duplicate', label: '重复' }]} />
<Button onClick={() => setPage(1)} type="primary"></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" />
</PageWrapper>
);
}
// ── Sessions ──
export function SessionPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['admin-sessions', page],
queryFn: () => learningAdminAPI.getSessions({ page, limit: 20 }),
});
const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : 'blue'}>{s}</Tag> },
{ title: '活跃秒数', dataIndex: 'totalActiveSeconds', width: 100 },
{ title: '开始', dataIndex: 'startedAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' },
];
return (
<PageWrapper title="学习会话">
<Table rowKey="id" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" />
</PageWrapper>
);
}
// ── Progress ──
export function ProgressPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['admin-progress', page],
queryFn: () => learningAdminAPI.getProgress({ page, limit: 20 }),
});
const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
{ title: '资料', dataIndex: 'materialId', width: 100, ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag>{s}</Tag> },
{ 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 ? '✅' : '' },
];
return (
<PageWrapper title="阅读进度">
<Table rowKey="id" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" />
</PageWrapper>
);
}
// ── Daily Activities ──
export function DailyActivityPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['admin-daily', page],
queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20 }),
});
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 },
];
return (
<PageWrapper title="每日学习活动">
<Table rowKey="id" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" />
</PageWrapper>
);
}
// ── Records ──
export function RecordPage() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['admin-records', page],
queryFn: () => learningAdminAPI.getRecords({ page, limit: 20 }),
});
const columns = [
{ title: '用户', dataIndex: 'userId', width: 100, ellipsis: true },
{ title: '标题', dataIndex: 'title', width: 200 },
{ title: '类型', dataIndex: 'recordType', width: 100, render: (t: string) => <Tag>{t}</Tag> },
{ title: '时长', dataIndex: 'durationSeconds', width: 80 },
{ title: '时间', dataIndex: 'occurredAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' },
];
return (
<PageWrapper title="学习记录">
<Table rowKey="id" columns={columns} dataSource={data?.items || []}
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small" />
</PageWrapper>
);
}
// ── Anomalies ──
export function AnomalyPage() {
const { data, isLoading } = useQuery({
queryKey: ['admin-anomalies'],
queryFn: () => learningAdminAPI.getAnomalies(),
});
const deltaCols = [
{ title: '事件ID', dataIndex: 'eventId', width: 200, ellipsis: true },
{ title: 'Delta', dataIndex: 'activeSecondsDelta', width: 80, render: (v: number) => <Tag color="red">{v}</Tag> },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (t: string) => t ? new Date(t).toLocaleString() : '-' },
];
return (
<PageWrapper title="异常数据">
<Tabs items={[
{ key: 'delta', label: 'Delta 超限 (>300)', children: <Table rowKey="id" columns={deltaCols} dataSource={(data as any)?.deltaOutliers || []} loading={isLoading} size="small" /> },
{ key: 'future', label: '未来时间戳', children: <Table rowKey="id" columns={deltaCols} dataSource={(data as any)?.futureEvents || []} loading={isLoading} size="small" /> },
]} />
</PageWrapper>
);
}
// ── User Diagnose ──
export function UserDiagnosePage() {
const [userId, setUserId] = useState('');
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-user-diag', userId],
queryFn: () => learningAdminAPI.getUserDiagnose(userId),
enabled: false,
});
return (
<PageWrapper title="用户诊断">
<Space style={{ marginBottom: 16 }}>
<Input placeholder="User ID" value={userId} onChange={e => setUserId(e.target.value)} style={{ width: 300 }} />
<Button type="primary" onClick={() => refetch()}></Button>
</Space>
{data && <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre>}
</PageWrapper>
);
}
// ── Material Diagnose ──
export function MaterialDiagnosePage() {
const [materialId, setMaterialId] = useState('');
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-mat-diag', materialId],
queryFn: () => learningAdminAPI.getMaterialDiagnose(materialId),
enabled: false,
});
return (
<PageWrapper title="资料诊断">
<Space style={{ marginBottom: 16 }}>
<Input placeholder="Material ID" value={materialId} onChange={e => setMaterialId(e.target.value)} style={{ width: 300 }} />
<Button type="primary" onClick={() => refetch()}></Button>
</Space>
{data && <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data, null, 2)}</pre>}
</PageWrapper>
);
}

View File

@ -6,6 +6,16 @@ const TaskAssistant = lazy(() => import('@/pages/TaskAssistant'))
const UserManagement = lazy(() => import('@/pages/UserManagement')) const UserManagement = lazy(() => import('@/pages/UserManagement'))
const MemberManagement = lazy(() => import('@/pages/MemberManagement')) const MemberManagement = lazy(() => import('@/pages/MemberManagement'))
const LearningDashboard = lazy(() => import('@/pages/learning/Dashboard'))
const ReadingEventPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.ReadingEventPage }))
const SessionPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.SessionPage }))
const ProgressPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.ProgressPage }))
const DailyActivityPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.DailyActivityPage }))
const RecordPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.RecordPage }))
const AnomalyPage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.AnomalyPage }))
const UserDiagnosePage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.UserDiagnosePage }))
const MaterialDiagnosePage = lazy(() => import('@/pages/learning/DataPages')).then(m => ({ default: m.MaterialDiagnosePage }))
export interface RouteConfig { export interface RouteConfig {
path: string path: string
title: string title: string
@ -27,4 +37,14 @@ export const routeConfig: RouteConfig[] = [
{ path: '/files', title: '文件与 COS', element: UserManagement }, { path: '/files', title: '文件与 COS', element: UserManagement },
{ path: '/settings', title: '系统配置', element: UserManagement, requiredRole: 'ADMIN' }, { path: '/settings', title: '系统配置', element: UserManagement, requiredRole: 'ADMIN' },
{ path: '/audit', title: '审计日志', element: UserManagement, requiredRole: 'ADMIN' }, { path: '/audit', title: '审计日志', element: UserManagement, requiredRole: 'ADMIN' },
// ── Learning Info ──
{ path: '/learning', title: '学习 Dashboard', element: LearningDashboard },
{ path: '/learning/events', title: '阅读事件', element: ReadingEventPage },
{ path: '/learning/sessions', title: '学习会话', element: SessionPage },
{ path: '/learning/progress', title: '阅读进度', element: ProgressPage },
{ path: '/learning/daily', title: '每日活动', element: DailyActivityPage },
{ path: '/learning/records', title: '学习记录', element: RecordPage },
{ path: '/learning/anomalies', title: '异常数据', element: AnomalyPage },
{ path: '/learning/user-diagnose', title: '用户诊断', element: UserDiagnosePage },
{ path: '/learning/material-diagnose', title: '资料诊断', element: MaterialDiagnosePage },
] ]

View File

@ -0,0 +1,25 @@
import { apiGet, apiPost } from './api';
export interface DashboardData {
overview: { totalEvents: number; todayEvents: number; failedEvents: number; duplicateEvents: number };
sessions: { active: number; interrupted: number; completed: number; total: number };
users: { activeToday: number; totalWithEvents: number };
materials: { totalRead: number; totalMarkedRead: number };
}
export const learningAdminAPI = {
getDashboard: () => apiGet<DashboardData>('/admin/learning/dashboard'),
getReadingEvents: (params?: Record<string, any>) => apiGet('/admin/learning/reading-events', params),
getFailedEvents: (params?: Record<string, any>) => apiGet('/admin/learning/reading-events/failed', params),
getSessions: (params?: Record<string, any>) => apiGet('/admin/learning/sessions', params),
getProgress: (params?: Record<string, any>) => apiGet('/admin/learning/progress', params),
getDailyActivities: (params?: Record<string, any>) => apiGet('/admin/learning/daily-activities', params),
getRecords: (params?: Record<string, any>) => apiGet('/admin/learning/records', params),
getUserTimeline: (userId: string) => apiGet('/admin/learning/user-timeline', { userId }),
getUserDiagnose: (userId: string) => apiGet('/admin/learning/user-diagnose', { userId }),
getMaterialDiagnose: (materialId: string) => apiGet('/admin/learning/material-diagnose', { materialId }),
getAnomalies: () => apiGet('/admin/learning/anomalies'),
getTemporaryMaterials: (params?: Record<string, any>) => apiGet('/admin/learning/temporary-materials', params),
recalculate: () => apiPost('/admin/learning/recalculate'),
exportData: (type: string) => apiGet('/admin/learning/export', { type }),
};