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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} 次阅读
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user