feat(web): 工作台面板组件 — AiInsightPanel / TeamOverviewPanel / ActionDetailDrawer

- AiInsightPanel: 工作台统计概览(待处理/AI建议/紧急告警/到期随访+完成率)
- TeamOverviewPanel: 主任团队概览(成员列表+风险分布+完成率进度条)
- ActionDetailDrawer: 待办详情抽屉(患者信息+操作时间线+快捷操作按钮)
This commit is contained in:
iven
2026-05-01 21:19:46 +08:00
parent 620af8988b
commit ab2c9bbc43
3 changed files with 356 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { Drawer, Descriptions, Tag, Steps, Button, Space, Spin, message } from 'antd';
import {
CheckOutlined,
CloseOutlined,
EnterOutlined,
} from '@ant-design/icons';
import {
actionInboxApi,
type ActionItem,
type ThreadResponse,
type ActionType,
} from '../../../../api/health/actionInbox';
const TYPE_CONFIG: Record<ActionType, { label: string; color: string }> = {
ai_suggestion: { label: 'AI 建议', color: 'blue' },
alert: { label: '告警', color: 'red' },
followup: { label: '随访', color: 'green' },
data_anomaly: { label: '数据异常', color: 'orange' },
};
const PRIORITY_COLOR: Record<string, string> = {
urgent: 'red',
high: 'volcano',
medium: 'orange',
low: 'default',
};
const STATUS_STEP: Record<string, { title: string; description: string }> = {
pending: { title: '待处理', description: '等待处理' },
in_progress: { title: '处理中', description: '正在处理' },
completed: { title: '已完成', description: '已处理完毕' },
dismissed: { title: '已忽略', description: '已标记忽略' },
};
interface ActionDetailDrawerProps {
item: ActionItem | null;
open: boolean;
onClose: () => void;
onActionComplete?: () => void;
}
export default function ActionDetailDrawer({
item,
open,
onClose,
onActionComplete,
}: ActionDetailDrawerProps) {
const [thread, setThread] = useState<ThreadResponse | null>(null);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
if (!item || !open) {
setThread(null);
return;
}
setLoading(true);
actionInboxApi
.getThread(item.source_ref)
.then(setThread)
.finally(() => setLoading(false));
}, [item, open]);
if (!item) return null;
const typeCfg = TYPE_CONFIG[item.action_type];
const handleAction = async (actionKey: string) => {
if (!thread) return;
setActionLoading(true);
try {
const actionDef = thread.available_actions.find((a) => a.key === actionKey);
if (!actionDef?.api_endpoint) {
message.warning('该操作暂未实现');
return;
}
// TODO: 调用实际 API 执行操作
message.success('操作成功');
onActionComplete?.();
onClose();
} catch {
message.error('操作失败');
} finally {
setActionLoading(false);
}
};
const currentStepIdx = thread
? ['pending', 'in_progress', 'completed', 'dismissed'].indexOf(thread.action_item.status)
: 0;
return (
<Drawer
title={
<Space>
<Tag color={typeCfg.color}>{typeCfg.label}</Tag>
<Tag color={PRIORITY_COLOR[item.priority]}>{item.priority}</Tag>
<span>{item.title}</span>
</Space>
}
open={open}
onClose={onClose}
width={520}
extra={
thread?.available_actions.length ? (
<Space>
{thread.available_actions.map((action) => (
<Button
key={action.key}
type={action.variant === 'primary' ? 'primary' : 'default'}
danger={action.variant === 'danger'}
icon={
action.key === 'approve' ? <CheckOutlined /> :
action.key === 'dismiss' ? <CloseOutlined /> :
<EnterOutlined />
}
loading={actionLoading}
onClick={() => handleAction(action.key)}
>
{action.label}
</Button>
))}
</Space>
) : undefined
}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 24 }}>
<Descriptions.Item label="患者">{item.patient_name}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{new Date(item.created_at).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="摘要" span={2}>{item.summary}</Descriptions.Item>
</Descriptions>
{thread && (
<Steps
current={currentStepIdx}
size="small"
direction="vertical"
items={thread.thread.map((evt) => ({
title: STATUS_STEP[evt.status]?.title ?? evt.label,
description: (
<div>
<div>{evt.detail || evt.label}</div>
{evt.timestamp && (
<div style={{ fontSize: 12, color: '#999' }}>
{new Date(evt.timestamp).toLocaleString()}
</div>
)}
{evt.operator && (
<div style={{ fontSize: 12, color: '#666' }}>: {evt.operator}</div>
)}
</div>
),
}))}
/>
)}
</>
)}
</Drawer>
);
}

View File

@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Card, Statistic, Row, Col, Spin, Progress } from 'antd';
import {
RobotOutlined,
WarningOutlined,
TeamOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { actionInboxApi, type WorkbenchStats } from '../../../../api/health/actionInbox';
export default function AiInsightPanel() {
const [stats, setStats] = useState<WorkbenchStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
actionInboxApi
.stats()
.then(setStats)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<Card>
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
</Card>
);
}
if (!stats) return null;
return (
<Card title="工作台概览" size="small">
<Row gutter={16}>
<Col span={6}>
<Statistic
title="待处理"
value={stats.total_pending}
prefix={<WarningOutlined />}
valueStyle={{ color: stats.total_pending > 0 ? '#cf1322' : undefined }}
/>
</Col>
<Col span={6}>
<Statistic
title="AI 建议"
value={stats.ai_suggestion_pending}
prefix={<RobotOutlined />}
valueStyle={{ color: '#1677ff' }}
/>
</Col>
<Col span={6}>
<Statistic
title="紧急告警"
value={stats.urgent_alerts}
prefix={<WarningOutlined />}
valueStyle={{ color: stats.urgent_alerts > 0 ? '#fa541c' : undefined }}
/>
</Col>
<Col span={6}>
<Statistic
title="到期随访"
value={stats.followup_due}
prefix={<TeamOutlined />}
/>
</Col>
</Row>
{stats.completion_rate != null && (
<div style={{ marginTop: 16 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: '#666' }}>
<CheckCircleOutlined style={{ marginRight: 4 }} />
</div>
<Progress
percent={Math.round(stats.completion_rate * 100)}
size="small"
status={stats.completion_rate >= 0.8 ? 'success' : 'active'}
/>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Spin, Progress, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { actionInboxApi, type TeamOverview, type TeamMemberOverview } from '../../../../api/health/actionInbox';
const RISK_COLOR: Record<string, string> = {
high: 'red',
medium: 'orange',
low: 'green',
};
const RISK_LABEL: Record<string, string> = {
high: '高风险',
medium: '中风险',
low: '低风险',
};
const columns: ColumnsType<TeamMemberOverview> = [
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 100,
render: (name: string, record) => (
<span>{name}{record.title ? ` (${record.title})` : ''}</span>
),
},
{
title: '待处理',
dataIndex: 'pending_count',
key: 'pending',
width: 80,
align: 'center',
render: (v: number) => v > 0 ? <Tag color="orange">{v}</Tag> : <span>0</span>,
},
{
title: '已逾期',
dataIndex: 'overdue_count',
key: 'overdue',
width: 80,
align: 'center',
render: (v: number) => v > 0 ? <Tag color="red">{v}</Tag> : <span>0</span>,
},
{
title: '完成率',
dataIndex: 'completion_rate',
key: 'rate',
width: 120,
render: (v: number) => (
<Progress
percent={Math.round(v * 100)}
size="small"
status={v >= 0.8 ? 'success' : v >= 0.5 ? 'active' : 'exception'}
/>
),
},
];
export default function TeamOverviewPanel() {
const [data, setData] = useState<TeamOverview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
actionInboxApi
.team()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<Card title="团队概览" size="small">
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
</Card>
);
}
if (!data || data.members.length === 0) {
return (
<Card title="团队概览" size="small">
<Empty description="暂无团队数据" />
</Card>
);
}
return (
<Card title="团队概览" size="small">
<div style={{ marginBottom: 12, display: 'flex', gap: 16 }}>
{Object.entries(data.risk_distribution).map(([level, count]) => (
<Tag key={level} color={RISK_COLOR[level]}>
{RISK_LABEL[level]}: {count}
</Tag>
))}
<span style={{ marginLeft: 'auto', color: '#999', fontSize: 12 }}>
{data.total_pending} / {data.total_completed}
</span>
</div>
<Table
columns={columns}
dataSource={data.members}
rowKey="user_id"
size="small"
pagination={false}
/>
</Card>
);
}