StatisticsDashboard 在 feat 重构时引入死链 navigate(routeConfig 不存在的
路由)+ value={0} 占位假数据。影响 100% 角色首屏。
死链修复(对照 routeConfig 实际路由):
- /health/follow-ups → /health/follow-up-tasks
- /health/vital-signs → /health/daily-monitoring
- /health/lab-reports → /health/patients(无化验单独立页)
- /health/points → /health/points-rules
- /health/articles/edit/:id → /health/articles/:id/edit(参数位置)
value={0}(OperatorDashboard 线下活动): 接入真实 offlineEventCount
(useStatsData 调 listOfflineEvents 取 total),消除假数据。
含 feat 进行中的 StatisticsDashboard 重构(卡片化 navigate + useStatsData
统一数据层)+ 清理未使用 import + fetchTopArticles effect 标注。待办:
AdminDashboard「咨询待回复」仍有 value={0} 占位。
131 lines
5.3 KiB
TypeScript
131 lines
5.3 KiB
TypeScript
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex, Button } from 'antd';
|
||
import {
|
||
TrophyOutlined,
|
||
FileTextOutlined,
|
||
CalendarOutlined,
|
||
ShoppingOutlined,
|
||
RightOutlined,
|
||
} from '@ant-design/icons';
|
||
import { useEffect, useState, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useStatsData } from './useStatsData';
|
||
import { articleApi, type ArticleListItem } from '../../../api/health/articles';
|
||
import { useCountUp } from '../../../hooks/useCountUp';
|
||
|
||
export function OperatorDashboard() {
|
||
const navigate = useNavigate();
|
||
const { pointsStats, offlineEventCount, loading } = useStatsData();
|
||
const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]);
|
||
const issuedCount = useCountUp(pointsStats?.total_issued ?? 0);
|
||
const spentCount = useCountUp(pointsStats?.total_spent ?? 0);
|
||
const activeCount = useCountUp(pointsStats?.active_accounts ?? 0);
|
||
const offlineCount = useCountUp(offlineEventCount);
|
||
|
||
const fetchTopArticles = useCallback(async () => {
|
||
try {
|
||
const result = await articleApi.list({ status: 'published', page: 1, page_size: 5 });
|
||
setTopArticles(result.data);
|
||
} catch {
|
||
// 静默降级
|
||
}
|
||
}, []);
|
||
|
||
// fetchTopArticles 内部 async setState(外部数据获取),非同步派生 state,属合理 effect 用法
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
|
||
|
||
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||
|
||
return (
|
||
<div>
|
||
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
|
||
<div>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>运营中心</Typography.Title>
|
||
<Typography.Text type="secondary">积分、内容、活动</Typography.Text>
|
||
</div>
|
||
</Flex>
|
||
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={12} sm={6}>
|
||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||
<Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||
<Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />}
|
||
suffix={pointsStats ? (
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
消费率{pointsStats.total_issued > 0 ? Math.round(pointsStats.total_spent / pointsStats.total_issued * 100) : 0}%
|
||
</Typography.Text>
|
||
) : undefined}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
|
||
<Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
<Col xs={12} sm={6}>
|
||
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/offline-events')}>
|
||
<Statistic title="线下活动" value={offlineCount} prefix={<CalendarOutlined />} />
|
||
</Card>
|
||
</Col>
|
||
|
||
{/* 积分排行 */}
|
||
<Col xs={24} md={12}>
|
||
<Card
|
||
title="积分消费排行"
|
||
size="small"
|
||
extra={
|
||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/points-rules')}>
|
||
查看全部
|
||
</Button>
|
||
}
|
||
>
|
||
<List
|
||
size="small"
|
||
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
|
||
locale={{ emptyText: '暂无数据' }}
|
||
renderItem={(item, idx) => (
|
||
<List.Item style={{ cursor: 'pointer' }} onClick={() => navigate(`/health/patients/${item.patient_id}`)}>
|
||
<Typography.Text>{idx + 1}. {item.patient_name}</Typography.Text>
|
||
<Typography.Text type="secondary">{item.total_earned} 分</Typography.Text>
|
||
</List.Item>
|
||
)}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
|
||
{/* 热门文章 */}
|
||
<Col xs={24} md={12}>
|
||
<Card
|
||
title="热门文章"
|
||
size="small"
|
||
extra={
|
||
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/articles')}>
|
||
内容管理
|
||
</Button>
|
||
}
|
||
>
|
||
<List
|
||
size="small"
|
||
dataSource={topArticles}
|
||
locale={{ emptyText: '暂无数据' }}
|
||
renderItem={(article) => (
|
||
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/articles/${article.id}/edit`)}>
|
||
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||
{article.view_count} 次阅读
|
||
</Typography.Text>
|
||
</List.Item>
|
||
)}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
</div>
|
||
);
|
||
}
|