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,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp';
import HealthDataCenter from './HealthDataCenter';
export function AdminDashboard() {
const navigate = useNavigate();
const { patientStats, followUpStats, healthDataStats, dialysisStats, doctorCount, loading } = useStatsData();
const patientCount = useCountUp(patientStats?.total_patients ?? 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' }} />;
const newThisMonth = patientStats?.new_this_month ?? 0;
const newThisWeek = patientStats?.new_this_week ?? 0;
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>
<Typography.Text type="secondary">
{newThisMonth > 0 ? `本月+${newThisMonth}` : ''} · +{newThisWeek} · {patientStats?.active_this_month ?? 0}
</Typography.Text>
</div>
</Flex>
<Row gutter={[16, 16]}>
<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 />} />
</Card>
</Col>
<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 />} />
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic
title="随访完成"
value={followUpStats?.completion_rate ?? 0}
precision={0}
suffix="%"
prefix={<SafetyCertificateOutlined />}
suffix={
<span style={{ fontSize: 12 }}>
% {followUpStats && followUpStats.overdue > 0 && (
<Typography.Text type="danger" style={{ fontSize: 11 }}>({followUpStats.overdue})</Typography.Text>
)}
</span>
}
/>
</Card>
</Col>
<Col xs={12} sm={8} lg={4}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic
title="体征上报"
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
@@ -61,10 +75,20 @@ export function AdminDashboard() {
</Card>
</Col>
<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 />} />
</Card>
</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>
{/* 健康数据中心 Tab */}

View File

@@ -2,9 +2,10 @@ import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex, Space, Bu
import {
TeamOutlined,
MessageOutlined,
SafetyCertificateOutlined,
MedicineBoxOutlined,
CalendarOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
AlertOutlined,
RightOutlined,
} from '@ant-design/icons';
@@ -137,7 +138,7 @@ export function DoctorDashboard() {
{/* 统计卡片 */}
<Col xs={12} sm={6}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/patients')}>
<Statistic
title="我的患者"
value={myPatientsCount}
@@ -151,19 +152,22 @@ export function DoctorDashboard() {
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/follow-up-tasks')}>
<Statistic
title="随访完成率"
value={p?.follow_up_rate ?? 0}
precision={0}
suffix="%"
prefix={<SafetyCertificateOutlined />}
styles={{ content: { color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' } }}
title="今日预约"
value={p?.today_appointments ?? 0}
prefix={<CalendarOutlined />}
suffix={p?.yesterday_today_appointments != null ? (() => {
const diff = (p.today_appointments ?? 0) - (p.yesterday_today_appointments ?? 0);
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>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/consultations')}>
<Statistic
title="本月咨询"
value={consultationsCount}
@@ -177,7 +181,7 @@ export function DoctorDashboard() {
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Card size="small" style={{ cursor: 'pointer' }} onClick={() => navigate('/health/daily-monitoring')}>
<Statistic
title="体征上报率"
value={p?.vital_signs_report_rate ?? 0}
@@ -192,13 +196,18 @@ export function DoctorDashboard() {
{/* 化验审核 */}
{p && p.pending_lab_reviews > 0 && (
<Col xs={24} md={12}>
<Card title={`化验审核 (${p.pending_lab_reviews}待审)`} size="small">
<List
size="small"
dataSource={[]}
locale={{ emptyText: '暂无待审核化验' }}
renderItem={() => <List.Item />}
/>
<Card
title={`化验审核 (${p.pending_lab_reviews}待审)`}
size="small"
extra={
<Button type="link" size="small" icon={<RightOutlined />} onClick={() => navigate('/health/patients')}>
</Button>
}
>
<Typography.Text type="secondary">
{p.pending_lab_reviews}
</Typography.Text>
</Card>
</Col>
)}
@@ -211,7 +220,7 @@ export function DoctorDashboard() {
dataSource={activeConsultations}
locale={{ emptyText: '暂无未读消息' }}
renderItem={(session) => (
<List.Item style={{ padding: '6px 0' }}>
<List.Item style={{ padding: '6px 0', cursor: 'pointer' }} onClick={() => navigate(`/health/consultations/${session.id}`)}>
<Space>
<Typography.Text strong style={{ fontSize: 13 }}>{session.patient_name ?? '患者'}</Typography.Text>
<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 { useNavigate } from 'react-router-dom';
import {
TeamOutlined,
SafetyCertificateOutlined,
@@ -11,6 +12,7 @@ import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
import { useCountUp } from '../../../hooks/useCountUp';
export function NurseDashboard() {
const navigate = useNavigate();
const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
const [loading, setLoading] = useState(true);
@@ -66,7 +68,7 @@ export function NurseDashboard() {
{p.abnormal_vital_signs}
</Typography.Text>
</Space>
<Typography.Link> </Typography.Link>
<Typography.Link onClick={() => navigate('/health/alert-dashboard')}> </Typography.Link>
</Flex>
</Card>
</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 {
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 { pointsStats, loading } = useStatsData();
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 {
@@ -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]);
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -41,12 +47,12 @@ export function OperatorDashboard() {
<Row gutter={[16, 16]}>
<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 />} />
</Card>
</Col>
<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 />}
suffix={pointsStats ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
@@ -57,26 +63,34 @@ export function OperatorDashboard() {
</Card>
</Col>
<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 />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
<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">
<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>
<Typography.Text>{idx + 1}. {item.patient_id?.slice(0, 8) ?? '未知'}</Typography.Text>
<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>
)}
@@ -86,13 +100,21 @@ export function OperatorDashboard() {
{/* 热门文章 */}
<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
size="small"
dataSource={topArticles}
locale={{ emptyText: '暂无数据' }}
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 type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{article.view_count}

View File

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