feat: 仪表盘角色自适应重构 — 4角色视图 + 后端个人工作量API
后端: - 新增 GET /health/admin/statistics/personal-stats 接口 - PersonalStatsResp: 13个个人维度统计字段 - 按医生/护士/管理员/运营角色聚合工作量数据 前端: - useDashboardRole hook: 按优先级 doctor>nurse>admin>operator 匹配角色 - DoctorDashboard: 今日工作台(日程/审核/消息/统计卡) - NurseDashboard: 随访监控台(异常提醒/队列/上报率) - AdminDashboard: 管理中心(5KPI + 健康数据Tab) - OperatorDashboard: 运营中心(积分/文章/活动) - StatisticsDashboard.tsx 重写为角色路由组件 - 删除旧区块:快捷入口/积分排行Top10/最近活动
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { Row, Col, Card, Statistic, Tabs, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
import HealthDataCenter from './HealthDataCenter';
|
||||
|
||||
export function AdminDashboard() {
|
||||
const { patientStats, followUpStats, healthDataStats, loading } = useStatsData();
|
||||
|
||||
if (loading && !patientStats) 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={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="患者总数" value={useCountUp(patientStats?.total_patients ?? 0)} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="本月预约" value={useCountUp(healthDataStats?.appointments?.this_month ?? 0)} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成"
|
||||
value={followUpStats?.completion_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="体征上报"
|
||||
value={healthDataStats?.vital_signs_report_rate?.report_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<MedicineBoxOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} lg={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="医护人数" value={useCountUp(0)} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 健康数据中心 Tab */}
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Tabs
|
||||
defaultActiveKey="dialysis"
|
||||
items={[
|
||||
{ key: 'dialysis', label: '透析管理', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'lab', label: '化验报告', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'appointments', label: '预约分析', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
{ key: 'vital-signs', label: '体征数据', children: <HealthDataCenter data={healthDataStats} /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Row, Col, Card, Statistic, List, Tag, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
MessageOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MedicineBoxOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { pointsApi, type PersonalStats } from '../../../api/health/points';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function DoctorDashboard() {
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { consultationStats } = useStatsData();
|
||||
|
||||
const fetchPersonal = useCallback(async () => {
|
||||
try {
|
||||
const data = await pointsApi.getPersonalStats();
|
||||
setPersonal(data);
|
||||
} catch {
|
||||
// 个人统计可能无权限,静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
|
||||
|
||||
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const p = personal;
|
||||
|
||||
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">
|
||||
{p ? `${p.today_appointments} 个预约 · ${p.today_follow_ups} 条待随访` : '加载中...'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 紧急提醒 */}
|
||||
{p && p.abnormal_vital_signs > 0 && (
|
||||
<Col span={24}>
|
||||
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
|
||||
<Typography.Text type="danger" strong>
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
{p.pending_lab_reviews > 0 && (
|
||||
<Typography.Text style={{ marginLeft: 16 }}>
|
||||
<Tag color="orange">{p.pending_lab_reviews} 份化验待审</Tag>
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="我的患者"
|
||||
value={useCountUp(p?.my_patients ?? 0)}
|
||||
prefix={<TeamOutlined />}
|
||||
suffix={p && p.new_patients_this_month > 0 ? (
|
||||
<Typography.Text type="success" style={{ fontSize: 12 }}>
|
||||
<ArrowUpOutlined /> {p.new_patients_this_month}新增
|
||||
</Typography.Text>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
valueStyle={{ color: (p?.follow_up_rate ?? 0) >= 80 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="本月咨询"
|
||||
value={useCountUp(p?.consultations_this_month ?? 0)}
|
||||
prefix={<MessageOutlined />}
|
||||
suffix={p && p.pending_consultations > 0 ? (
|
||||
<Typography.Text type="warning" style={{ fontSize: 12 }}>
|
||||
{p.pending_consultations}待回复
|
||||
</Typography.Text>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="体征上报率"
|
||||
value={p?.vital_signs_report_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<MedicineBoxOutlined />}
|
||||
valueStyle={{ color: (p?.vital_signs_report_rate ?? 0) >= 70 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 化验审核 */}
|
||||
{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>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 咨询消息 */}
|
||||
<Col xs={24} md={p && p.pending_lab_reviews > 0 ? 12 : 24}>
|
||||
<Card title={`咨询消息 (${consultationStats?.pending_reply ?? 0}未读)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无未读消息' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx
Normal file
124
apps/web/src/pages/health/StatisticsDashboard/NurseDashboard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Row, Col, Card, Statistic, Progress, List, Spin, Typography, Flex, Space } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
CalendarOutlined,
|
||||
AlertOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { pointsApi, type PersonalStats } from '../../../api/health/points';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function NurseDashboard() {
|
||||
const [personal, setPersonal] = useState<PersonalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchPersonal = useCallback(async () => {
|
||||
try {
|
||||
const data = await pointsApi.getPersonalStats();
|
||||
setPersonal(data);
|
||||
} catch {
|
||||
// 静默降级
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
|
||||
|
||||
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
|
||||
const p = personal;
|
||||
|
||||
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">
|
||||
{p ? `今日待随访 ${p.today_follow_ups} 人 · 体征异常 ${p.abnormal_vital_signs} 人` : '加载中...'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 异常提醒 */}
|
||||
{p && p.abnormal_vital_signs > 0 && (
|
||||
<Col span={24}>
|
||||
<Card size="small" style={{ borderLeft: '4px solid #ff4d4f' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<AlertOutlined style={{ color: '#ff4d4f' }} />
|
||||
<Typography.Text type="danger" strong>
|
||||
{p.abnormal_vital_signs} 位患者体征异常
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Link>查看全部 →</Typography.Link>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* 今日随访队列 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title={`今日随访队列 (${p?.today_follow_ups ?? 0}人)`} size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '今日暂无随访任务' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 今日体征上报 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="今日体征上报" size="small">
|
||||
<Flex vertical align="center" style={{ padding: '16px 0' }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={Math.round(p?.vital_signs_report_rate ?? 0)}
|
||||
size={120}
|
||||
format={pct => <span style={{ fontSize: 24 }}>{pct}%</span>}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
{p?.vital_signs_reported ?? 0}已上报
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">|</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{(p?.vital_signs_total ?? 0) - (p?.vital_signs_reported ?? 0)}未上报
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 统计卡 */}
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日预约" value={useCountUp(p?.today_appointments ?? 0)} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="随访完成率"
|
||||
value={p?.follow_up_rate ?? 0}
|
||||
precision={0}
|
||||
suffix="%"
|
||||
prefix={<SafetyCertificateOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="逾期随访" value={useCountUp(p?.overdue_follow_ups ?? 0)} prefix={<TeamOutlined />}
|
||||
valueStyle={{ color: (p?.overdue_follow_ups ?? 0) > 0 ? '#cf1322' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Row, Col, Card, Statistic, List, Spin, Typography, Flex } from 'antd';
|
||||
import {
|
||||
TrophyOutlined,
|
||||
FileTextOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStatsData } from './useStatsData';
|
||||
import { useCountUp } from '../../../hooks/useCountUp';
|
||||
|
||||
export function OperatorDashboard() {
|
||||
const { pointsStats, loading } = useStatsData();
|
||||
|
||||
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">
|
||||
<Statistic title="积分发放" value={useCountUp(pointsStats?.total_issued ?? 0)} prefix={<TrophyOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="积分消费" value={useCountUp(pointsStats?.total_spent ?? 0)} 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">
|
||||
<Statistic title="活跃账户" value={useCountUp(pointsStats?.active_accounts ?? 0)} prefix={<FileTextOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="线下活动" value={0} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 积分排行 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="积分消费排行" size="small">
|
||||
<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>
|
||||
<Typography.Text type="secondary">{item.total_earned} 分</Typography.Text>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 热门文章 */}
|
||||
<Col xs={24} md={12}>
|
||||
<Card title="热门文章" size="small">
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[]}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
renderItem={() => <List.Item />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user