Files
hms/apps/web/src/components/ActionThreadDrawer.tsx
iven 81dd3d2bda feat(web): 行动收件箱前端 — API + Drawer + 列表页 + 路由
- actionInbox.ts: API 调用层,list + getThread
- ActionThreadDrawer: 上下文线程抽屉,时间线 + 操作按钮
- ActionInbox: 列表页,Tabs 筛选 + 分页 + 点击打开 Drawer
- App.tsx: 注册 /health/action-inbox 路由
2026-05-01 16:36:24 +08:00

227 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { Drawer, Timeline, Button, Spin, Result, Space, Tag } from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
MinusCircleOutlined,
CloseCircleOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import {
actionInboxApi,
type ActionItem,
type ThreadResponse,
type ActionPriority,
} from '../api/health/actionInbox';
import client from '../api/client';
interface Props {
open: boolean;
item: ActionItem | null;
onClose: () => void;
onActionComplete?: () => void;
}
const PRIORITY_COLOR: Record<ActionPriority, string> = {
urgent: 'red',
high: 'orange',
medium: 'blue',
low: 'default',
};
const PRIORITY_LABEL: Record<ActionPriority, string> = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低',
};
export default function ActionThreadDrawer({
open,
item,
onClose,
onActionComplete,
}: Props) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [threadData, setThreadData] = useState<ThreadResponse | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const fetchThread = useCallback(async () => {
if (!item) return;
setLoading(true);
setError(null);
try {
const data = await actionInboxApi.getThread(item.source_ref);
setThreadData(data);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '获取线程失败';
setError(msg);
} finally {
setLoading(false);
}
}, [item]);
useEffect(() => {
if (open && item) fetchThread();
if (!open) setThreadData(null);
}, [open, item, fetchThread]);
const handleAction = async (endpoint: string, key: string) => {
setActionLoading(key);
try {
await client.post(endpoint, { action: key });
onActionComplete?.();
fetchThread();
} catch {
// 全局拦截器处理
} finally {
setActionLoading(null);
}
};
const handleLinkClick = (linkTo?: string) => {
if (linkTo) {
onClose();
navigate(linkTo);
}
};
const timelineDot = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
case 'in_progress':
return <ClockCircleOutlined style={{ color: '#faad14' }} />;
case 'dismissed':
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
default:
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />;
}
};
return (
<Drawer
title={null}
open={open}
onClose={onClose}
width={480}
styles={{ body: { padding: 0 } }}
>
{loading && (
<div style={{ padding: 40, textAlign: 'center' }}>
<Spin size="large" />
</div>
)}
{error && (
<div style={{ padding: 24 }}>
<Result status="error" title="加载失败" subTitle={error} />
</div>
)}
{threadData && !loading && (
<>
<div
style={{
padding: '16px 24px',
borderBottom: '1px solid #f0f0f0',
}}
>
<div style={{ fontSize: 16, fontWeight: 600 }}>
{threadData.action_item.title}
</div>
<div
style={{ marginTop: 4, color: '#8c8c8c', fontSize: 13 }}
>
<UserOutlined /> {threadData.action_item.patient_name}
<Tag
color={PRIORITY_COLOR[threadData.action_item.priority]}
style={{ marginLeft: 8 }}
>
{PRIORITY_LABEL[threadData.action_item.priority]}
</Tag>
</div>
</div>
<div style={{ padding: '20px 24px' }}>
<div style={{ fontWeight: 500, marginBottom: 16 }}>
</div>
<Timeline
items={threadData.thread.map((evt) => ({
color:
evt.status === 'completed'
? 'green'
: evt.status === 'in_progress'
? 'blue'
: 'gray',
dot: timelineDot(evt.status),
children: (
<div>
<div
style={{
fontWeight:
evt.status === 'in_progress' ? 600 : 400,
}}
>
{evt.label}
</div>
{evt.detail && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
{evt.detail}
</div>
)}
{evt.timestamp && (
<div style={{ color: '#8c8c8c', fontSize: 12 }}>
{new Date(evt.timestamp).toLocaleString('zh-CN')}
</div>
)}
{evt.link_to && (
<a
onClick={() => handleLinkClick(evt.link_to)}
style={{ fontSize: 12 }}
>
</a>
)}
</div>
),
}))}
/>
</div>
{threadData.available_actions.length > 0 && (
<div
style={{
padding: '12px 24px',
borderTop: '1px solid #f0f0f0',
}}
>
<Space>
{threadData.available_actions.map((action) => (
<Button
key={action.key}
type={action.variant === 'primary' ? 'primary' : 'default'}
danger={action.variant === 'danger'}
loading={actionLoading === action.key}
onClick={() => {
if (action.api_endpoint) {
handleAction(action.api_endpoint, action.key);
}
}}
>
{action.label}
</Button>
))}
</Space>
</div>
)}
</>
)}
</Drawer>
);
}