feat: show user/kb/material names in admin learning tables
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 7s
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e2d94742e2
commit
ba4e35d413
@ -0,0 +1,512 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Table, Input, Select, Space, Tag, Button, Tabs, Drawer, Descriptions, DatePicker, Timeline, Typography } from 'antd';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { learningAdminAPI } from '../../services/learningAdmin';
|
||||||
|
import { eventTypeLabels, eventStatusLabels, sessionStatusLabels, progressStatusLabels, targetTypeLabels, recordTypeLabels } from '../../constants/labels';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
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 params = new URLSearchParams(window.location.search);
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
const csid = params.get('clientSessionId');
|
||||||
|
if (csid) init.clientSessionId = csid;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
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, isLoading: detailLoading } = useQuery({
|
||||||
|
queryKey: ['admin-event-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', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
||||||
|
{ title: '类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || t}</Tag> },
|
||||||
|
{ title: 'Delta(s)', dataIndex: 'activeSecondsDelta', key: 'delta', width: 70 },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||||
|
render: (s: string) => <Tag color={s === 'processed' ? 'green' : s === 'failed' ? 'red' : s === 'duplicate' ? 'orange' : 'blue'}>{eventStatusLabels[s] || s}</Tag> },
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||||
|
render: (t: string) => t ? dayjs(t).format('MM-DD HH:mm:ss') : '-' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper title="阅读事件">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<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 }} />
|
||||||
|
<Input placeholder="Client Session ID" allowClear onChange={e => setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
|
||||||
|
<Select placeholder="事件类型" allowClear style={{ width: 130 }} onChange={v => setFilters(f => ({ ...f, eventType: v || '' }))}
|
||||||
|
options={Object.entries(eventTypeLabels).map(([v, l]) => ({ value: v, label: l }))} />
|
||||||
|
<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>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="eventId" columns={columns} dataSource={data?.items || []}
|
||||||
|
loading={isLoading} pagination={{ current: page, total: data?.total || 0, onChange: setPage }} size="small"
|
||||||
|
onRow={(record: any) => ({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sessions ──
|
||||||
|
|
||||||
|
export function SessionPage() {
|
||||||
|
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-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: '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', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 90, ellipsis: true },
|
||||||
|
{ title: '类型', dataIndex: 'targetType', key: 'targetType', width: 80, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
||||||
|
{ title: '模式', dataIndex: 'mode', key: 'mode', width: 70, render: (v?: string) => v || '-' },
|
||||||
|
{ title: 'KB', dataIndex: 'knowledgeBaseId', key: 'kbId', width: 80, ellipsis: true, render: (_: any, r: any) => r.kbName || r.knowledgeBaseId },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||||
|
render: (s: string) => <Tag color={s === 'active' ? 'green' : s === 'interrupted' ? 'red' : s === 'completed' ? 'blue' : 'orange'}>{sessionStatusLabels[s] || 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 (
|
||||||
|
<PageWrapper title="学习会话">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<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 }} />
|
||||||
|
<Input placeholder="Client Session ID" allowClear onChange={e => setFilters(f => ({ ...f, clientSessionId: e.target.value }))} style={{ width: 170 }} />
|
||||||
|
<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: any) => ({ 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>
|
||||||
|
)}
|
||||||
|
{detail?.clientSessionId && (
|
||||||
|
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||||
|
<Button type="primary" onClick={() => window.location.href = `/learning/events?clientSessionId=${encodeURIComponent(detail.clientSessionId)}`}>查看关联事件</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Progress ──
|
||||||
|
|
||||||
|
export function ProgressPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-progress', page, filters],
|
||||||
|
queryFn: () => learningAdminAPI.getProgress({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 110, ellipsis: true },
|
||||||
|
{ title: '目标类型', dataIndex: 'readingTargetType', key: 'targetType', width: 85, render: (v?: string) => v ? <Tag>{targetTypeLabels[v] || v}</Tag> : '-' },
|
||||||
|
{ 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) => <Tag color={s === 'reading' ? 'blue' : s === 'completed' ? 'green' : 'orange'}>{progressStatusLabels[s] || 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 (
|
||||||
|
<PageWrapper title="阅读进度">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<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: any) => ({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daily Activities ──
|
||||||
|
|
||||||
|
export function DailyActivityPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-daily', page, filters],
|
||||||
|
queryFn: () => learningAdminAPI.getDailyActivities({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.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) => <span style={{ color: v > 3600 ? '#cf1322' : v > 600 ? '#faad14' : '#3f8600' }}>{v}</span> },
|
||||||
|
{ 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 (
|
||||||
|
<PageWrapper title="每日学习活动">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<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: any) => ({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Records ──
|
||||||
|
|
||||||
|
export function RecordPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [detail, setDetail] = useState<any>(null);
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-records', page, filters],
|
||||||
|
queryFn: () => learningAdminAPI.getRecords({ page, limit: 20, ...filters }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '用户', dataIndex: 'userId', key: 'userId', render: (_: any, r: any) => r.userName || r.userId, width: 90, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true, render: (_: any, r: any) => r.materialName || (r.materialId ? r.materialId.slice(0,12) : '-') },
|
||||||
|
{ title: '标题', dataIndex: 'title', key: 'title', width: 180 },
|
||||||
|
{ title: '类型', dataIndex: 'recordType', key: 'recordType', width: 100, render: (t: string) => <Tag>{recordTypeLabels[t] || t}</Tag> },
|
||||||
|
{ 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 (
|
||||||
|
<PageWrapper title="学习记录">
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<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 }} />
|
||||||
|
<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: any) => ({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Failed / Warning / Duplicate Events ──
|
||||||
|
|
||||||
|
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({
|
||||||
|
queryKey: ['admin-anomalies', activeTab, page, errorCodeFilter],
|
||||||
|
queryFn: () => learningAdminAPI.getReadingEvents({ page, limit: 20, status: statusParam, errorCode: errorCodeFilter || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
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', render: (_: any, r: any) => r.userName || r.userId, width: 100, ellipsis: true },
|
||||||
|
{ title: '资料', dataIndex: 'materialId', key: 'materialId', render: (_: any, r: any) => r.materialName || r.materialId, width: 100, ellipsis: true },
|
||||||
|
{ title: '事件类型', dataIndex: 'eventType', key: 'eventType', width: 120, render: (t: string) => <Tag>{eventTypeLabels[t] || 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 (
|
||||||
|
<PageWrapper title="异常事件">
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Input placeholder="错误码筛选" allowClear value={errorCodeFilter}
|
||||||
|
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: any) => ({ 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: any) => ({ 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: any) => ({ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Timeline ──
|
||||||
|
|
||||||
|
export function UserTimelinePage() {
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const { data, isFetching, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-user-timeline', userId],
|
||||||
|
queryFn: () => learningAdminAPI.getUserTimeline(userId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = Array.isArray(data)
|
||||||
|
? (data as any[]).map((entry: any, i: number) => ({
|
||||||
|
key: i,
|
||||||
|
color: entry.recordType === 'session' ? 'blue' : entry.recordType === 'reading' ? 'green' : 'gray',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{entry.occurredAt ? dayjs(entry.occurredAt).format('YYYY-MM-DD HH:mm:ss') : '-'}</Text>
|
||||||
|
<div><Tag style={{ marginTop: 4 }}>{recordTypeLabels[entry.recordType] || entry.recordType}</Tag></div>
|
||||||
|
<Text strong>{entry.title || '-'}</Text>
|
||||||
|
{entry.durationSeconds != null && <div><Text type="secondary">时长 {entry.durationSeconds}s</Text></div>}
|
||||||
|
{entry.materialId && <div><Text type="secondary">资料 {entry.materialId}</Text></div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
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()} loading={isFetching}>查询</Button>
|
||||||
|
</Space>
|
||||||
|
{data === undefined && !isFetching && <Text type="secondary">输入用户 ID 后点击查询</Text>}
|
||||||
|
{isFetching && <Text type="secondary">加载中...</Text>}
|
||||||
|
{data && !Array.isArray(data) ? <pre style={{ background: '#f5f5f5', padding: 16, borderRadius: 8, overflow: 'auto', maxHeight: 600 }}>{JSON.stringify(data as any, null, 2)}</pre> : null}
|
||||||
|
{Array.isArray(data) && data.length === 0 && !isFetching && <Text type="secondary">该用户暂无学习记录</Text>}
|
||||||
|
{items.length > 0 && <Timeline items={items} />}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User Diagnose ──
|
||||||
|
|
||||||
|
export function UserDiagnosePage() {
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-user-diag', userId],
|
||||||
|
queryFn: () => learningAdminAPI.getUserDiagnose(userId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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> : null}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Material Diagnose ──
|
||||||
|
|
||||||
|
export function MaterialDiagnosePage() {
|
||||||
|
const [materialId, setMaterialId] = useState('');
|
||||||
|
const { data, refetch } = useQuery({
|
||||||
|
queryKey: ['admin-mat-diag', materialId],
|
||||||
|
queryFn: () => learningAdminAPI.getMaterialDiagnose(materialId),
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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> : null}
|
||||||
|
</PageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user