feat: ADMIN-INFO admin learning dashboard frontend (21/21)
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 8s
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:
parent
8144bdc42f
commit
a9a7d651bb
70
docs/admin-learning-info-design.md
Normal file
70
docs/admin-learning-info-design.md
Normal 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` |
|
||||
37
src/pages/learning/Dashboard.tsx
Normal file
37
src/pages/learning/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/pages/learning/DataPages.tsx
Normal file
218
src/pages/learning/DataPages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,16 @@ const TaskAssistant = lazy(() => import('@/pages/TaskAssistant'))
|
||||
const UserManagement = lazy(() => import('@/pages/UserManagement'))
|
||||
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 {
|
||||
path: string
|
||||
title: string
|
||||
@ -27,4 +37,14 @@ export const routeConfig: RouteConfig[] = [
|
||||
{ path: '/files', title: '文件与 COS', element: UserManagement },
|
||||
{ path: '/settings', 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 },
|
||||
]
|
||||
|
||||
25
src/services/learningAdmin.ts
Normal file
25
src/services/learningAdmin.ts
Normal 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 }),
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user