fix(web): 统计页空列表接入真实 API + 运营待办去硬编码
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- DoctorDashboard: 咨询消息接入 consultationApi.listSessions
- NurseDashboard: 随访队列接入 followUpApi.listTasks
- OperatorDashboard: 热门文章接入 articleApi.list
- OperatorWorkbench: 5 条硬编码待办替换为 actionInboxApi 真实数据
This commit is contained in:
iven
2026-05-03 00:02:58 +08:00
parent 603af83aa9
commit 2e4d98c479
4 changed files with 112 additions and 34 deletions

View File

@@ -12,6 +12,7 @@ import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { pointsApi, type PersonalStats } from '../../../api/health/points';
import { alertApi, type Alert } from '../../../api/health/alerts';
import { consultationApi, type Session } from '../../../api/health/consultations';
import { useStatsData } from './useStatsData';
import { useCountUp } from '../../../hooks/useCountUp';
import { SEVERITY_COLOR, SEVERITY_LABEL } from '../../../constants/health';
@@ -20,6 +21,7 @@ export function DoctorDashboard() {
const navigate = useNavigate();
const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [recentAlerts, setRecentAlerts] = useState<Alert[]>([]);
const [activeConsultations, setActiveConsultations] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const { consultationStats } = useStatsData();
const myPatientsCount = useCountUp(personal?.my_patients ?? 0);
@@ -45,7 +47,16 @@ export function DoctorDashboard() {
}
}, []);
useEffect(() => { fetchPersonal(); fetchRecentAlerts(); }, [fetchPersonal, fetchRecentAlerts]);
const fetchConsultations = useCallback(async () => {
try {
const result = await consultationApi.listSessions({ status: 'active', page: 1, page_size: 5 });
setActiveConsultations(result.data);
} catch {
// 静默降级
}
}, []);
useEffect(() => { fetchPersonal(); fetchRecentAlerts(); fetchConsultations(); }, [fetchPersonal, fetchRecentAlerts, fetchConsultations]);
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -197,9 +208,19 @@ export function DoctorDashboard() {
<Card title={`咨询消息 (${consultationStats?.pending_reply ?? 0}未读)`} size="small">
<List
size="small"
dataSource={[]}
dataSource={activeConsultations}
locale={{ emptyText: '暂无未读消息' }}
renderItem={() => <List.Item />}
renderItem={(session) => (
<List.Item style={{ padding: '6px 0' }}>
<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>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{session.last_message_at ? new Date(session.last_message_at).toLocaleString('zh-CN') : ''}
</Typography.Text>
</Space>
</List.Item>
)}
/>
</Card>
</Col>

View File

@@ -7,10 +7,12 @@ import {
} from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react';
import { pointsApi, type PersonalStats } from '../../../api/health/points';
import { followUpApi, type FollowUpTask } from '../../../api/health/followUp';
import { useCountUp } from '../../../hooks/useCountUp';
export function NurseDashboard() {
const [personal, setPersonal] = useState<PersonalStats | null>(null);
const [followUpTasks, setFollowUpTasks] = useState<FollowUpTask[]>([]);
const [loading, setLoading] = useState(true);
const appointmentCount = useCountUp(personal?.today_appointments ?? 0);
const overdueCount = useCountUp(personal?.overdue_follow_ups ?? 0);
@@ -26,7 +28,16 @@ export function NurseDashboard() {
}
}, []);
useEffect(() => { fetchPersonal(); }, [fetchPersonal]);
const fetchFollowUps = useCallback(async () => {
try {
const result = await followUpApi.listTasks({ status: 'pending', page: 1, page_size: 10 });
setFollowUpTasks(result.data);
} catch {
// 静默降级
}
}, []);
useEffect(() => { fetchPersonal(); fetchFollowUps(); }, [fetchPersonal, fetchFollowUps]);
if (loading && !personal) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
@@ -63,12 +74,22 @@ export function NurseDashboard() {
{/* 今日随访队列 */}
<Col xs={24} md={12}>
<Card title={`今日随访队列 (${p?.today_follow_ups ?? 0}人)`} size="small">
<Card title={`今日随访队列 (${followUpTasks.length}人)`} size="small">
<List
size="small"
dataSource={[]}
dataSource={followUpTasks}
locale={{ emptyText: '今日暂无随访任务' }}
renderItem={() => <List.Item />}
renderItem={(task) => (
<List.Item style={{ padding: '6px 0' }}>
<Space>
<Typography.Text strong style={{ fontSize: 13 }}>{task.patient_name ?? '患者'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{task.follow_up_type}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{task.planned_date ? new Date(task.planned_date).toLocaleDateString('zh-CN') : ''}
</Typography.Text>
</Space>
</List.Item>
)}
/>
</Card>
</Col>

View File

@@ -5,15 +5,29 @@ import {
CalendarOutlined,
ShoppingOutlined,
} from '@ant-design/icons';
import { useEffect, useState, useCallback } from 'react';
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 [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 fetchTopArticles = useCallback(async () => {
try {
const result = await articleApi.list({ status: 'published', page: 1, page_size: 5 });
setTopArticles(result.data);
} catch {
// 静默降级
}
}, []);
useEffect(() => { fetchTopArticles(); }, [fetchTopArticles]);
if (loading && !pointsStats) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
return (
@@ -75,9 +89,16 @@ export function OperatorDashboard() {
<Card title="热门文章" size="small">
<List
size="small"
dataSource={[]}
dataSource={topArticles}
locale={{ emptyText: '暂无数据' }}
renderItem={() => <List.Item />}
renderItem={(article) => (
<List.Item style={{ padding: '6px 0' }}>
<Typography.Text ellipsis style={{ flex: 1, fontSize: 13 }}>{article.title}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{article.view_count}
</Typography.Text>
</List.Item>
)}
/>
</Card>
</Col>

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../../../stores/auth';
import { actionInboxApi, type WorkbenchStats } from '../../../../api/health/actionInbox';
import { actionInboxApi, type WorkbenchStats, type ActionItem } from '../../../../api/health/actionInbox';
import { useStatsData } from '../../StatisticsDashboard/useStatsData';
import {
dashboardApi,
@@ -9,10 +9,22 @@ import {
type ArticleStatsResp,
} from '../../../../api/health/dashboard';
const TYPE_ICON: Record<string, { icon: string; bg: string; color: string }> = {
ai_suggestion: { icon: '🤖', bg: '#F0F9FF', color: '#0284C7' },
alert: { icon: '⚠️', bg: '#FFF1F2', color: '#E11D48' },
followup: { icon: '📋', bg: '#F0FDF4', color: '#16A34A' },
data_anomaly: { icon: '📊', bg: '#F5F3FF', color: '#7C3AED' },
};
const PRIORITY_LABEL: Record<string, string> = {
urgent: '紧急', high: '高', medium: '中', low: '低',
};
export default function OperatorWorkbench() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const [stats, setStats] = useState<WorkbenchStats | null>(null);
const [actionItems, setActionItems] = useState<ActionItem[]>([]);
const [pointsActivity, setPointsActivity] = useState<PointsActivityItem[]>([]);
const [articleStats, setArticleStats] = useState<ArticleStatsResp | null>(null);
const statsData = useStatsData();
@@ -22,6 +34,10 @@ export default function OperatorWorkbench() {
.then((s) => setStats(s ?? null))
.catch(() => {});
actionInboxApi.list({ status: 'pending', page: 1, page_size: 5 })
.then((r) => setActionItems(r.data))
.catch(() => {});
dashboardApi.getPointsRecentActivity()
.then((d) => setPointsActivity(d ?? []))
.catch(() => {});
@@ -42,14 +58,6 @@ export default function OperatorWorkbench() {
{ label: '待审核订单', value: stats?.total_pending ?? 0, color: '#E11D48', trend: '', trendDir: 'down' },
];
const todos = [
{ icon: '🎁', bg: '#FFF1F2', color: '#E11D48', title: `审核 ${stats?.total_pending ?? 0} 笔积分兑换订单`, sub: 'AI 标记异常订单需确认', action: '紧急', path: '/health/points-orders' },
{ icon: '📝', bg: '#F0FDF4', color: '#16A34A', title: '发布新科普文章', sub: `已发布 ${articleStats?.published ?? 0} 篇,草稿 ${articleStats?.draft ?? 0}`, action: '发布', path: '/health/articles/new' },
{ icon: '🎪', bg: '#F0F9FF', color: '#0284C7', title: '推送活动报名提醒', sub: '报名截止临近,需推广', action: '推送', path: '/health/offline-events' },
{ icon: '📊', bg: '#F5F3FF', color: '#7C3AED', title: '整理上周运营周报', sub: '数据已就绪', action: '查看', path: '/health/statistics' },
{ icon: '👥', bg: '#FFFBEB', color: '#D97706', title: '跟进沉默用户', sub: '7 天未上报体征,建议关怀', action: '跟进', path: '/health/patients' },
];
return (
<div>
{/* AI Hero Card */}
@@ -98,22 +106,29 @@ export default function OperatorWorkbench() {
<span style={{ fontSize: 11, color: '#2563EB', cursor: 'pointer' }} onClick={() => navigate('/health/action-inbox')}> </span>
</div>
<div>
{todos.map((todo) => (
<div
key={todo.path}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 18px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
onClick={() => navigate(todo.path)}
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ width: 28, height: 28, borderRadius: 6, background: todo.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, flexShrink: 0 }}>{todo.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{todo.title}</div>
<div style={{ fontSize: 11, color: '#94A3B8' }}>{todo.sub}</div>
</div>
<div style={{ fontSize: 11, padding: '3px 10px', borderRadius: 4, border: '1px solid #E2E8F0', color: '#475569', cursor: 'pointer', flexShrink: 0 }}>{todo.action}</div>
</div>
))}
{actionItems.length === 0 ? (
<div style={{ padding: 20, textAlign: 'center', color: '#94A3B8', fontSize: 13 }}></div>
) : (
actionItems.map((item) => {
const cfg = TYPE_ICON[item.action_type] ?? TYPE_ICON.alert;
return (
<div
key={item.id}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 18px', borderBottom: '1px solid #F1F5F9', cursor: 'pointer', transition: 'all 0.15s' }}
onClick={() => navigate('/health/action-inbox')}
onMouseEnter={(e) => { e.currentTarget.style.background = '#EFF6FF'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
>
<div style={{ width: 28, height: 28, borderRadius: 6, background: cfg.bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, flexShrink: 0 }}>{cfg.icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{item.title}</div>
<div style={{ fontSize: 11, color: '#94A3B8' }}>{item.patient_name} · {item.summary}</div>
</div>
<div style={{ fontSize: 11, padding: '3px 10px', borderRadius: 4, border: '1px solid #E2E8F0', color: '#475569', cursor: 'pointer', flexShrink: 0 }}>{PRIORITY_LABEL[item.priority] ?? '处理'}</div>
</div>
);
})
)}
</div>
</div>