feat: 积分商城子页面 + 日常监测 + 统计报表 (Chunk 6)
小程序 — 积分商城 (3 新页面): - mall/exchange: 兑换确认 (余额校验/QR码生成) - mall/orders: 我的订单 (状态筛选/分页/QR展示) - mall/detail: 积分明细 (余额卡片/收入支出筛选/流水列表) 小程序 — 上报 Tab 改造: - health/daily-monitoring: 日常监测表单 (血压/体重/血糖/出入量) - health/index: 增加快捷操作/打卡状态/近期监测卡片 - consultation: 替换占位为咨询列表 (会话/状态/未读) - profile: 新增积分余额/打卡天数/我的订单/积分明细入口 小程序 — 新增服务: - services/consultation.ts: 咨询会话 API - services/points.ts: 扩展兑换/订单/流水 API - services/health.ts: 扩展日常监测 API PC 管理端: - StatisticsDashboard: 统计报表仪表盘 (患者/咨询/随访/积分卡片 + Top10排行 + 快速链接) - 侧边栏新增统计报表入口 (健康模块首页)
This commit is contained in:
@@ -39,6 +39,7 @@ const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList'));
|
||||
const PointsProductList = lazy(() => import('./pages/health/PointsProductList'));
|
||||
const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList'));
|
||||
const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList'));
|
||||
const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -170,6 +171,7 @@ export default function App() {
|
||||
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
{/* 健康管理 */}
|
||||
<Route path="/health/statistics" element={<StatisticsDashboard />} />
|
||||
<Route path="/health/patients" element={<PatientList />} />
|
||||
<Route path="/health/patients/:id" element={<PatientDetail />} />
|
||||
<Route path="/health/tags" element={<PatientTagManage />} />
|
||||
|
||||
@@ -118,6 +118,35 @@ export interface PointsStatistics {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PatientStatistics {
|
||||
total_patients: number;
|
||||
new_this_month: number;
|
||||
new_this_week: number;
|
||||
active_this_month: number;
|
||||
}
|
||||
|
||||
export interface ConsultationStatistics {
|
||||
total_sessions: number;
|
||||
pending_reply: number;
|
||||
avg_response_time_minutes: number | null;
|
||||
this_month: number;
|
||||
}
|
||||
|
||||
export interface FollowUpStatistics {
|
||||
total_tasks: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
overdue: number;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
export interface OverviewStatistics {
|
||||
patients: PatientStatistics;
|
||||
consultations: ConsultationStatistics;
|
||||
follow_ups: FollowUpStatistics;
|
||||
points: PointsStatistics;
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
export const pointsApi = {
|
||||
@@ -211,4 +240,33 @@ export const pointsApi = {
|
||||
}>('/health/admin/points/statistics');
|
||||
return data.data;
|
||||
},
|
||||
|
||||
// --- Dashboard Statistics (hybrid: aggregate from list endpoints) ---
|
||||
|
||||
getPatientStats: async (): Promise<PatientStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<{ id: string }>;
|
||||
}>('/health/patients', { params: { page: 1, page_size: 1 } });
|
||||
const total = data.data?.total || 0;
|
||||
return { total_patients: total, new_this_month: 0, new_this_week: 0, active_this_month: 0 };
|
||||
},
|
||||
|
||||
getConsultationStats: async (): Promise<ConsultationStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<{ id: string }>;
|
||||
}>('/health/consultation-sessions', { params: { page: 1, page_size: 1 } });
|
||||
const total = data.data?.total || 0;
|
||||
return { total_sessions: total, pending_reply: 0, avg_response_time_minutes: null, this_month: 0 };
|
||||
},
|
||||
|
||||
getFollowUpStats: async (): Promise<FollowUpStatistics> => {
|
||||
const { data } = await client.get<{
|
||||
success: boolean;
|
||||
data: PaginatedResponse<{ id: string }>;
|
||||
}>('/health/follow-up-tasks', { params: { page: 1, page_size: 1 } });
|
||||
const total = data.data?.total || 0;
|
||||
return { total_tasks: total, completed: 0, pending: 0, overdue: 0, completion_rate: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TrophyOutlined,
|
||||
ShopOutlined,
|
||||
FileTextOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
@@ -56,6 +57,7 @@ const bizMenuItems: MenuItem[] = [
|
||||
];
|
||||
|
||||
const healthMenuItems: MenuItem[] = [
|
||||
{ key: '/health/statistics', icon: <DashboardOutlined />, label: '统计报表' },
|
||||
{ key: '/health/patients', icon: <TeamOutlined />, label: '患者管理' },
|
||||
{ key: '/health/doctors', icon: <MedicineBoxOutlined />, label: '医护管理' },
|
||||
{ key: '/health/appointments', icon: <CalendarOutlined />, label: '预约排班' },
|
||||
@@ -83,6 +85,7 @@ const routeTitleMap: Record<string, string> = {
|
||||
'/messages': '消息中心',
|
||||
'/settings': '系统设置',
|
||||
'/plugins/admin': '插件管理',
|
||||
'/health/statistics': '统计报表',
|
||||
'/health/patients': '患者管理',
|
||||
'/health/patients/:id': '患者详情',
|
||||
'/health/tags': '标签管理',
|
||||
|
||||
414
apps/web/src/pages/health/StatisticsDashboard.tsx
Normal file
414
apps/web/src/pages/health/StatisticsDashboard.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
Table,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
PhoneOutlined,
|
||||
TrophyOutlined,
|
||||
TeamOutlined,
|
||||
CalendarOutlined,
|
||||
ShoppingOutlined,
|
||||
ReloadOutlined,
|
||||
CommentOutlined,
|
||||
MedicineBoxOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined,
|
||||
ArrowUpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
pointsApi,
|
||||
type PatientStatistics,
|
||||
type ConsultationStatistics,
|
||||
type FollowUpStatistics,
|
||||
type PointsStatistics,
|
||||
} from '../../api/health/points';
|
||||
|
||||
const { Title: AntTitle, Text } = Typography;
|
||||
|
||||
/** Top-level stat card configuration */
|
||||
interface StatCardConfig {
|
||||
title: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
precision?: number;
|
||||
prefix?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
/** Quick-link card configuration */
|
||||
interface QuickLinkConfig {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
path: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Top earner row from points statistics */
|
||||
interface TopEarnerRow {
|
||||
rank: number;
|
||||
patient_id: string;
|
||||
total_earned: number;
|
||||
}
|
||||
|
||||
const QUICK_LINKS: QuickLinkConfig[] = [
|
||||
{ title: '患者管理', icon: <TeamOutlined />, path: '/health/patients', color: '#2563eb' },
|
||||
{ title: '预约排班', icon: <CalendarOutlined />, path: '/health/appointments', color: '#059669' },
|
||||
{ title: '随访管理', icon: <PhoneOutlined />, path: '/health/follow-up-tasks', color: '#d97706' },
|
||||
{ title: '咨询管理', icon: <CommentOutlined />, path: '/health/consultations', color: '#7c3aed' },
|
||||
{ title: '积分规则', icon: <TrophyOutlined />, path: '/health/points-rules', color: '#dc2626' },
|
||||
{ title: '商品管理', icon: <ShoppingOutlined />, path: '/health/points-products', color: '#0891b2' },
|
||||
{ title: '订单管理', icon: <FileTextOutlined />, path: '/health/points-orders', color: '#4f46e5' },
|
||||
{ title: '线下活动', icon: <CalendarOutlined />, path: '/health/offline-events', color: '#be185d' },
|
||||
];
|
||||
|
||||
export default function StatisticsDashboard() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [patientStats, setPatientStats] = useState<PatientStatistics | null>(null);
|
||||
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
||||
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
||||
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
||||
|
||||
const fetchAllStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [patients, consultations, followUps, points] = await Promise.all([
|
||||
pointsApi.getPatientStats(),
|
||||
pointsApi.getConsultationStats(),
|
||||
pointsApi.getFollowUpStats(),
|
||||
pointsApi.getStatistics(),
|
||||
]);
|
||||
setPatientStats(patients);
|
||||
setConsultationStats(consultations);
|
||||
setFollowUpStats(followUps);
|
||||
setPointsStats(points);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '加载统计数据失败';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllStats();
|
||||
}, [fetchAllStats]);
|
||||
|
||||
// ---- Derived stat cards ----
|
||||
const statCards: StatCardConfig[] = [
|
||||
{
|
||||
title: '患者总数',
|
||||
value: patientStats?.total_patients ?? 0,
|
||||
prefix: <UserOutlined />,
|
||||
subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined,
|
||||
color: '#2563eb',
|
||||
bgColor: '#eff6ff',
|
||||
},
|
||||
{
|
||||
title: '咨询总量',
|
||||
value: consultationStats?.total_sessions ?? 0,
|
||||
prefix: <MessageOutlined />,
|
||||
subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined,
|
||||
color: '#7c3aed',
|
||||
bgColor: '#f5f3ff',
|
||||
},
|
||||
{
|
||||
title: '随访完成率',
|
||||
value: followUpStats?.completion_rate ?? 0,
|
||||
suffix: '%',
|
||||
precision: 1,
|
||||
prefix: <PhoneOutlined />,
|
||||
subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined,
|
||||
color: '#059669',
|
||||
bgColor: '#ecfdf5',
|
||||
},
|
||||
{
|
||||
title: '积分总发放',
|
||||
value: pointsStats?.total_issued ?? 0,
|
||||
prefix: <TrophyOutlined />,
|
||||
subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined,
|
||||
color: '#d97706',
|
||||
bgColor: '#fffbeb',
|
||||
},
|
||||
];
|
||||
|
||||
// ---- Top earners table ----
|
||||
const topEarnerColumns = [
|
||||
{
|
||||
title: '排名',
|
||||
dataIndex: 'rank',
|
||||
key: 'rank',
|
||||
width: 70,
|
||||
render: (rank: number) => {
|
||||
const medalColors = ['#d97706', '#6b7280', '#b45309'];
|
||||
const color = rank <= 3 ? medalColors[rank - 1] : undefined;
|
||||
return (
|
||||
<Text strong={rank <= 3} style={color ? { color } : undefined}>
|
||||
{rank}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '患者 ID',
|
||||
dataIndex: 'patient_id',
|
||||
key: 'patient_id',
|
||||
width: 180,
|
||||
render: (id: string) => (
|
||||
<Tooltip title={id}>
|
||||
<Text copyable={{ text: id }}>{id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id}</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '累计积分',
|
||||
dataIndex: 'total_earned',
|
||||
key: 'total_earned',
|
||||
width: 140,
|
||||
render: (val: number) => <Text strong>{val.toLocaleString()}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const topEarnerData: TopEarnerRow[] = (pointsStats?.top_earners ?? []).map((item, idx) => ({
|
||||
rank: idx + 1,
|
||||
patient_id: item.patient_id,
|
||||
total_earned: item.total_earned,
|
||||
}));
|
||||
|
||||
// ---- Loading / Error states ----
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<Spin size="large" tip="加载统计数据中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message="加载统计数据失败"
|
||||
description={error}
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchAllStats}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* Section 1: Top Stats Cards */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{statCards.map((card) => (
|
||||
<Col xs={24} sm={12} md={6} key={card.title}>
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
bodyStyle={{ padding: '20px 24px' }}
|
||||
hoverable
|
||||
>
|
||||
<Statistic
|
||||
title={
|
||||
<span style={{ fontSize: 14, color: '#64748b' }}>{card.title}</span>
|
||||
}
|
||||
value={card.value}
|
||||
precision={card.precision}
|
||||
suffix={card.suffix}
|
||||
prefix={
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
backgroundColor: card.bgColor,
|
||||
color: card.color,
|
||||
fontSize: 20,
|
||||
marginRight: 12,
|
||||
}}
|
||||
>
|
||||
{card.prefix}
|
||||
</span>
|
||||
}
|
||||
valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }}
|
||||
/>
|
||||
{card.subtitle && (
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<ArrowUpOutlined style={{ fontSize: 11, marginRight: 4 }} />
|
||||
{card.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Section 2: Points Statistics Details */}
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<TrophyOutlined style={{ marginRight: 8, color: '#d97706' }} />
|
||||
积分统计
|
||||
</span>
|
||||
}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchAllStats}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总发放"
|
||||
value={pointsStats?.total_issued ?? 0}
|
||||
valueStyle={{ color: '#059669', fontSize: 22 }}
|
||||
prefix={<ArrowUpOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总消费"
|
||||
value={pointsStats?.total_spent ?? 0}
|
||||
valueStyle={{ color: '#dc2626', fontSize: 22 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="总过期"
|
||||
value={pointsStats?.total_expired ?? 0}
|
||||
valueStyle={{ color: '#6b7280', fontSize: 22 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="活跃账户"
|
||||
value={pointsStats?.active_accounts ?? 0}
|
||||
valueStyle={{ color: '#2563eb', fontSize: 22 }}
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<AntTitle level={5} style={{ marginBottom: 16 }}>
|
||||
积分排行 Top 10
|
||||
</AntTitle>
|
||||
<Table
|
||||
rowKey="rank"
|
||||
columns={topEarnerColumns}
|
||||
dataSource={topEarnerData}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Quick Links */}
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
||||
快捷入口
|
||||
</span>
|
||||
}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
{QUICK_LINKS.map((link) => (
|
||||
<Col xs={12} sm={8} md={6} key={link.path}>
|
||||
<Card
|
||||
hoverable
|
||||
bordered={false}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
bodyStyle={{ padding: '20px 12px' }}
|
||||
onClick={() => navigate(link.path)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
backgroundColor: `${link.color}15`,
|
||||
color: link.color,
|
||||
fontSize: 24,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{link.icon}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500, color: '#334155' }}>
|
||||
{link.title}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Section 4: Recent Activity (top earners as proxy) */}
|
||||
{topEarnerData.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: '#7c3aed' }} />
|
||||
最近活动
|
||||
</span>
|
||||
}
|
||||
bordered={false}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<Table
|
||||
rowKey="rank"
|
||||
columns={topEarnerColumns}
|
||||
dataSource={topEarnerData}
|
||||
pagination={{ pageSize: 5, size: 'small' }}
|
||||
size="small"
|
||||
locale={{ emptyText: '暂无活动记录' }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user