fix(web): 统计页空列表接入真实 API + 运营待办去硬编码
- DoctorDashboard: 咨询消息接入 consultationApi.listSessions - NurseDashboard: 随访队列接入 followUpApi.listTasks - OperatorDashboard: 热门文章接入 articleApi.list - OperatorWorkbench: 5 条硬编码待办替换为 actionInboxApi 真实数据
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user