feat(web): 行动收件箱前端 — API + Drawer + 列表页 + 路由
- actionInbox.ts: API 调用层,list + getThread - ActionThreadDrawer: 上下文线程抽屉,时间线 + 操作按钮 - ActionInbox: 列表页,Tabs 筛选 + 分页 + 点击打开 Drawer - App.tsx: 注册 /health/action-inbox 路由
This commit is contained in:
226
apps/web/src/components/ActionThreadDrawer.tsx
Normal file
226
apps/web/src/components/ActionThreadDrawer.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user