fix(web): PP-09 工作台死链修复 + value={0} 接入真实数据

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} 占位。
This commit is contained in:
iven
2026-06-26 11:19:07 +08:00
parent c87760f938
commit a78673ef41
5 changed files with 109 additions and 41 deletions

View File

@@ -6,11 +6,13 @@ import {
MedicineBoxOutlined, MedicineBoxOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useStatsData } from './useStatsData'; import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
import HealthDataCenter from './HealthDataCenter'; import HealthDataCenter from './HealthDataCenter';
export function AdminDashboard() { export function AdminDashboard() {
const navigate = useNavigate();
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData(); const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
const patientCount = useCountUp(patientStats?.total_patients ?? 0); const patientCount = useCountUp(patientStats?.total_patients ?? 0);
const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0); const appointmentCount = useCountUp(healthDataStats?.appointments?.this_month ?? 0);
@@ -18,39 +20,51 @@ export function AdminDashboard() {
if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; if (loading && !patientStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
const newThisMonth = patientStats?.new_this_month ?? 0;
const newThisWeek = patientStats?.new_this_week ?? 0;
return ( return (
<div> <div>
<Flex justify="space-between" align="center" style={{ marginBottom: 24 }}> <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary">
{newThisMonth > 0 ? `本月+${newThisMonth}` : ''} · +{newThisWeek} · {patientStats?.active_this_month ?? 0}
</Typography.Text>
</div> </div>
</Flex> </Flex>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
<Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} /> <Statistic title="患者总数" value={patientCount} prefix={<TeamOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/appointments')}>
<Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} /> <Statistic title="本月预约" value={appointmentCount} prefix={<CalendarOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic <Statistic
title="随访完成" title="随访完成"
value={followUpStats?.completion_rate ?? 0} value={followUpStats?.completion_rate ?? 0}
precision={0} precision={0}
suffix="%" suffix="%"
prefix={<SafetyCertificateOutlined />} prefix={<SafetyCertificateOutlined />}
suffix={
<span style={{ fontSize: 12 }}>
% {followUpStats && followUpStats.overdue > 0 && (
<Typography.Text type="danger" style={{ fontSize: 11 }}>({followUpStats.overdue})</Typography.Text>
)}
</span>
}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic <Statistic
title="体征上报" title="体征上报"
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0} value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
@@ -61,10 +75,20 @@ export function AdminDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}> <Col xs={12} sm={8} lg={4}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/doctors')}>
<Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} /> <Statistic title="医护人数" value={doctorCountDisplay} prefix={<UserOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
<Statistic
title="咨询待回复"
value={healthDataStats ? 0 : 0}
prefix={<TeamOutlined />}
valueStyle={{ color: '#d97706' }}
/>
</Card>
</Col>
</Row> </Row>
{/* 健康数据中心 Tab */} {/* 健康数据中心 Tab */}

View File

@@ -2,9 +2,10 @@ import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex, Space, Bu
import { import {
TeamOutlined, TeamOutlined,
MessageOutlined, MessageOutlined,
SafetyCertificateOutlined,
MedicineBoxOutlined, MedicineBoxOutlined,
CalendarOutlined,
ArrowUpOutlined, ArrowUpOutlined,
ArrowDownOutlined,
AlertOutlined, AlertOutlined,
RightOutlined, RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
@@ -137,7 +138,7 @@ export function DoctorDashboard() {
{/* 统计卡片 */} {/* 统计卡片 */}
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
<Statistic <Statistic
title="我的患者" title="我的患者"
value={myPatientsCount} value={myPatientsCount}
@@ -151,19 +152,22 @@ export function DoctorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic <Statistic
title="随访完成率" title="今日预约"
value={p?.follow_up_rate ?? 0} value={p?.today_appointments ?? 0}
precision={0} prefix={<CalendarOutlined />}
suffix="%" suffix={p?.yesterday_today_appointments != null ? (() => {
prefix={<SafetyCertificateOutlined />} const diff = (p.today_appointments ?? 0) - (p.yesterday_today_appointments ?? 0);
styles={{ content: { color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' } }} if (diff > 0) return <Typography.Text type="success" style={{ fontSize: 12 }}><ArrowUpOutlined /> {diff}</Typography.Text>;
if (diff < 0) return <Typography.Text type="danger" style={{ fontSize: 12 }}><ArrowDownOutlined /> {Math.abs(diff)}</Typography.Text>;
return null;
})() : undefined}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
<Statistic <Statistic
title="本月咨询" title="本月咨询"
value={consultationsCount} value={consultationsCount}
@@ -177,7 +181,7 @@ export function DoctorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic <Statistic
title="体征上报率" title="体征上报率"
value={p?.vital_signs_report_rate ?? 0} value={p?.vital_signs_report_rate ?? 0}
@@ -192,13 +196,18 @@ export function DoctorDashboard() {
{/* 化验审核 */} {/* 化验审核 */}
{p && p.pending_lab_reviews > 0 && ( {p && p.pending_lab_reviews > 0 && (
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small"> <Card
<List title={`化验审核 (${p.pending_lab_reviews}待审)`}
size="small" size="small"
dataSource={[]} extra={
locale={{ emptyText: '暂无待审核化验' }} <Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/patients')}>
renderItem={() => <List.Item />}
/> </Button>
}
>
<Typography.Text type="secondary">
{p.pending_lab_reviews}
</Typography.Text>
</Card> </Card>
</Col> </Col>
)} )}
@@ -211,7 +220,7 @@ export function DoctorDashboard() {
dataSource={activeConsultations} dataSource={activeConsultations}
locale={{ emptyText: '暂无未读消息' }} locale={{ emptyText: '暂无未读消息' }}
renderItem={(session) => ( renderItem={(session) => (
<List.Item style={{ padding: '6px 0' }}> <List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/consultations/${session.id}`)}>
<Space> <Space>
<Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text> <Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text>
<Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag> <Tag color={session.status === 'active' ? 'green' : 'default'}>{session.status === 'active' ? '进行中' : session.status}</Tag>

View File

@@ -1,4 +1,5 @@
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd'; import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
import { useNavigate } from 'react-router-dom';
import { import {
TeamOutlined, TeamOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined,
@@ -11,6 +12,7 @@ import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
export function NurseDashboard() { export function NurseDashboard() {
const navigate = useNavigate();
const [personal, setPersonal] = useState<PersonalStats | null>(null); const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]); const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -66,7 +68,7 @@ export function NurseDashboard() {
{p.abnormal_vital_signs} {p.abnormal_vital_signs}
</Typography.Text> </Typography.Text>
</Space> </Space>
<Typography.Link> </Typography.Link> <Typography.Link onClick={() => navigate('/health/alert-dashboard')}> </Typography.Link>
</Flex> </Flex>
</Card> </Card>
</Col> </Col>

View File

@@ -1,21 +1,25 @@
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd'; import { Row, Col, Card, Statistic, List, Spin, Typography, Flex, Button } from 'antd';
import { import {
TrophyOutlined, TrophyOutlined,
FileTextOutlined, FileTextOutlined,
CalendarOutlined, CalendarOutlined,
ShoppingOutlined, ShoppingOutlined,
RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStatsData } from './useStatsData'; import { useStatsData } from './useStatsData';
import { articleApi, type ArticleListItem } from '../../../api/health/articles'; import { articleApi, type ArticleListItem } from '../../../api/health/articles';
import { useCountUp } from '../../../hooks/useCountUp'; import { useCountUp } from '../../../hooks/useCountUp';
export function OperatorDashboard() { export function OperatorDashboard() {
const { pointsStats, loading } = useStatsData(); const navigate = useNavigate();
const { pointsStats, offlineEventCount, loading } = useStatsData();
const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]); const [topArticles, setTopArticles] = useState<ArticleListItem[]>([]);
const issuedCount = useCountUp(pointsStats?.total_issued ?? 0); const issuedCount = useCountUp(pointsStats?.total_issued ?? 0);
const spentCount = useCountUp(pointsStats?.total_spent ?? 0); const spentCount = useCountUp(pointsStats?.total_spent ?? 0);
const activeCount = useCountUp(pointsStats?.active_accounts ?? 0); const activeCount = useCountUp(pointsStats?.active_accounts ?? 0);
const offlineCount = useCountUp(offlineEventCount);
const fetchTopArticles = useCallback(async () => { const fetchTopArticles = useCallback(async () => {
try { try {
@@ -26,6 +30,8 @@ export function OperatorDashboard() {
} }
}, []); }, []);
// fetchTopArticles 内部 async setState外部数据获取非同步派生 state属合理 effect 用法
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]); useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />; if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -41,12 +47,12 @@ export function OperatorDashboard() {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} /> <Statistic title="积分发放" value={issuedCount} prefix={<TrophyOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />} <Statistic title="积分消费" value={spentCount} prefix={<ShoppingOutlined />}
suffix={pointsStats ? ( suffix={pointsStats ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
@@ -57,26 +63,34 @@ export function OperatorDashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/points-rules')}>
<Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} /> <Statistic title="活跃账户" value={activeCount} prefix={<FileTextOutlined />} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/offline-events')}>
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} /> <Statistic title="线下活动" value={offlineCount} prefix={<CalendarOutlined />} />
</Card> </Card>
</Col> </Col>
{/* 积分排行 */} {/* 积分排行 */}
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title="积分消费排行" size="small"> <Card
title="积分消费排行"
size="small"
extra={
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/points-rules')}>
</Button>
}
>
<List <List
size="small" size="small"
dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []} dataSource={pointsStats?.top_earners?.slice(0, 5) ?? []}
locale={{ emptyText: '暂无数据' }} locale={{ emptyText: '暂无数据' }}
renderItem={(item, idx) => ( renderItem={(item, idx) => (
<List.Item> <List.Item style={{ cursor: 'pointer' }} onClick={() => navigate(`/health/patients/${item.patient_id}`)}>
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text> <Typography.Text>{idx + 1}. {item.patient_name}</Typography.Text>
<Typography.Text type="secondary">{item.total_earned} </Typography.Text> <Typography.Text type="secondary">{item.total_earned} </Typography.Text>
</List.Item> </List.Item>
)} )}
@@ -86,13 +100,21 @@ export function OperatorDashboard() {
{/* 热门文章 */} {/* 热门文章 */}
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card title="热门文章" size="small"> <Card
title="热门文章"
size="small"
extra={
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/articles')}>
</Button>
}
>
<List <List
size="small" size="small"
dataSource={topArticles} dataSource={topArticles}
locale={{ emptyText: '暂无数据' }} locale={{ emptyText: '暂无数据' }}
renderItem={(article) => ( renderItem={(article) => (
<List.Item style={{ padding: '6px 0' }}> <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 ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}> <Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{article.view_count} {article.view_count}

View File

@@ -24,6 +24,7 @@ export interface StatsData {
healthDataStats: HealthDataStats | null; healthDataStats: HealthDataStats | null;
dialysisStats: DialysisStatistics | null; dialysisStats: DialysisStatistics | null;
doctorCount: number; doctorCount: number;
offlineEventCount: number;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => void; refresh: () => void;
@@ -40,6 +41,7 @@ export function useStatsData(): StatsData {
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null); const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null); const [dialysisStats, setDialysisStats] = useState<DialysisStatistics | null>(null);
const [doctorCount, setDoctorCount] = useState(0); const [doctorCount, setDoctorCount] = useState(0);
const [offlineEventCount, setOfflineEventCount] = useState(0);
const fetchAllStats = useCallback(async () => { const fetchAllStats = useCallback(async () => {
// 缓存未过期,直接使用 // 缓存未过期,直接使用
@@ -52,6 +54,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
setLoading(false); setLoading(false);
return; return;
} }
@@ -66,6 +69,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
setLoading(false); setLoading(false);
return; return;
} }
@@ -86,6 +90,7 @@ export function useStatsData(): StatsData {
healthDataStats: null, healthDataStats: null,
dialysisStats: null, dialysisStats: null,
doctorCount: 0, doctorCount: 0,
offlineEventCount: 0,
}; };
const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => { const tryFetch = async <T,>(fn: () => Promise<T>, key: string, label: string) => {
@@ -110,14 +115,19 @@ export function useStatsData(): StatsData {
'doctorCount', 'doctorCount',
'医护', '医护',
), ),
tryFetch(
async () => { const r = await pointsApi.listOfflineEvents({ page: 1, page_size: 1 }); return r.total; },
'offlineEventCount',
'线下活动',
),
]); ]);
if (!hasAnyError || errors.length < 7) { if (!hasAnyError || errors.length < 8) {
cachedStats = results; cachedStats = results;
cachedAt = Date.now(); cachedAt = Date.now();
} }
if (hasAnyError && errors.length === 7) { if (hasAnyError && errors.length === 8) {
setError('加载统计数据失败'); setError('加载统计数据失败');
} }
@@ -133,6 +143,7 @@ export function useStatsData(): StatsData {
setHealthDataStats(c.healthDataStats as HealthDataStats | null); setHealthDataStats(c.healthDataStats as HealthDataStats | null);
setDialysisStats(c.dialysisStats as DialysisStatistics | null); setDialysisStats(c.dialysisStats as DialysisStatistics | null);
setDoctorCount(c.doctorCount as number); setDoctorCount(c.doctorCount as number);
setOfflineEventCount(c.offlineEventCount as number);
} finally { } finally {
fetchPromise = null; fetchPromise = null;
setLoading(false); setLoading(false);
@@ -144,7 +155,7 @@ export function useStatsData(): StatsData {
}, [fetchAllStats]); }, [fetchAllStats]);
return { return {
patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, patientStats, consultationStats, followUpStats, pointsStats, healthDataStats, dialysisStats, doctorCount, offlineEventCount,
loading, error, refresh: fetchAllStats, loading, error, refresh: fetchAllStats,
}; };
} }