feat(web): 行动收件箱前端 — API + Drawer + 列表页 + 路由
- actionInbox.ts: API 调用层,list + getThread - ActionThreadDrawer: 上下文线程抽屉,时间线 + 操作按钮 - ActionInbox: 列表页,Tabs 筛选 + 分页 + 点击打开 Drawer - App.tsx: 注册 /health/action-inbox 路由
This commit is contained in:
@@ -48,6 +48,8 @@ const AlertList = lazy(() => import('./pages/health/AlertList'));
|
|||||||
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard'));
|
||||||
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList'));
|
||||||
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
const DeviceManage = lazy(() => import('./pages/health/DeviceManage'));
|
||||||
|
const DialysisManageList = lazy(() => import('./pages/health/DialysisManageList'));
|
||||||
|
const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
|
||||||
|
|
||||||
// 内容管理
|
// 内容管理
|
||||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||||
@@ -254,6 +256,8 @@ export default function App() {
|
|||||||
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
<Route path="/health/alert-dashboard" element={<AlertDashboard />} />
|
||||||
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
<Route path="/health/alert-rules" element={<AlertRuleList />} />
|
||||||
<Route path="/health/devices" element={<DeviceManage />} />
|
<Route path="/health/devices" element={<DeviceManage />} />
|
||||||
|
<Route path="/health/dialysis" element={<DialysisManageList />} />
|
||||||
|
<Route path="/health/action-inbox" element={<ActionInbox />} />
|
||||||
{/* 内容管理 */}
|
{/* 内容管理 */}
|
||||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
|||||||
66
apps/web/src/api/health/actionInbox.ts
Normal file
66
apps/web/src/api/health/actionInbox.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import client from '../client';
|
||||||
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
export type ActionType = 'ai_suggestion' | 'alert' | 'followup' | 'data_anomaly';
|
||||||
|
export type ActionPriority = 'urgent' | 'high' | 'medium' | 'low';
|
||||||
|
export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'dismissed';
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
action_type: ActionType;
|
||||||
|
priority: ActionPriority;
|
||||||
|
status: ActionStatus;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
patient_id: string;
|
||||||
|
patient_name: string;
|
||||||
|
source_ref: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadEvent {
|
||||||
|
step: string;
|
||||||
|
label: string;
|
||||||
|
status: ActionStatus;
|
||||||
|
detail?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
operator?: string;
|
||||||
|
link_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
variant: 'primary' | 'danger' | 'default';
|
||||||
|
api_endpoint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadResponse {
|
||||||
|
action_item: ActionItem;
|
||||||
|
thread: ThreadEvent[];
|
||||||
|
available_actions: ActionDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionInboxApi = {
|
||||||
|
list: async (params?: {
|
||||||
|
status?: string;
|
||||||
|
type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<ActionItem>;
|
||||||
|
}>('/health/action-inbox', { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getThread: async (sourceRef: string) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: ThreadResponse;
|
||||||
|
}>(`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
apps/web/src/pages/health/ActionInbox.tsx
Normal file
170
apps/web/src/pages/health/ActionInbox.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Tag, List, Badge, Tabs, Spin, Empty } from 'antd';
|
||||||
|
import { PageContainer } from '../../components/PageContainer';
|
||||||
|
import ActionThreadDrawer from '../../components/ActionThreadDrawer';
|
||||||
|
import {
|
||||||
|
actionInboxApi,
|
||||||
|
type ActionItem,
|
||||||
|
type ActionType,
|
||||||
|
type ActionPriority,
|
||||||
|
} from '../../api/health/actionInbox';
|
||||||
|
import { formatRelative } from '../../utils/format';
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<ActionType, { label: string; color: string }> = {
|
||||||
|
ai_suggestion: { label: 'AI建议', color: '#722ed1' },
|
||||||
|
alert: { label: '告警', color: '#f5222d' },
|
||||||
|
followup: { label: '随访', color: '#1890ff' },
|
||||||
|
data_anomaly: { label: '异常', color: '#fa8c16' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_LABEL: Record<ActionPriority, string> = {
|
||||||
|
urgent: '紧急',
|
||||||
|
high: '高',
|
||||||
|
medium: '中',
|
||||||
|
low: '低',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_COLOR: Record<ActionPriority, string> = {
|
||||||
|
urgent: 'red',
|
||||||
|
high: 'orange',
|
||||||
|
medium: 'blue',
|
||||||
|
low: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'pending', label: '待处理' },
|
||||||
|
{ key: 'in_progress', label: '进行中' },
|
||||||
|
{ key: 'completed', label: '已完成' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BADGE_STATUS: Record<string, 'error' | 'processing' | 'default'> = {
|
||||||
|
pending: 'error',
|
||||||
|
in_progress: 'processing',
|
||||||
|
completed: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActionInbox() {
|
||||||
|
const [items, setItems] = useState<ActionItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<ActionItem | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (p: number, status?: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await actionInboxApi.list({
|
||||||
|
page: p,
|
||||||
|
page_size: 20,
|
||||||
|
status: status === 'all' ? undefined : status,
|
||||||
|
});
|
||||||
|
setItems(resp.data);
|
||||||
|
setTotal(resp.total);
|
||||||
|
setPage(p);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(1, 'all');
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const handleTabChange = (key: string) => {
|
||||||
|
setStatusFilter(key);
|
||||||
|
fetchData(1, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemClick = (item: ActionItem) => {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionComplete = () => {
|
||||||
|
fetchData(page, statusFilter);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="行动收件箱"
|
||||||
|
subtitle={`共 ${total} 项待办`}
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={statusFilter}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
items={STATUS_TABS.map((tab) => ({
|
||||||
|
key: tab.key,
|
||||||
|
label: tab.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{items.length === 0 && !loading ? (
|
||||||
|
<Empty description="暂无待办事项" />
|
||||||
|
) : (
|
||||||
|
<List
|
||||||
|
dataSource={items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => fetchData(p, statusFilter),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
}}
|
||||||
|
renderItem={(item) => {
|
||||||
|
const typeConf =
|
||||||
|
TYPE_CONFIG[item.action_type] ??
|
||||||
|
({ label: '未知', color: '#999' } as {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<List.Item
|
||||||
|
style={{ cursor: 'pointer', padding: '12px 16px' }}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<Badge
|
||||||
|
status={BADGE_STATUS[item.status] ?? 'default'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag color={typeConf.color}>{typeConf.label}</Tag>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<Tag color={PRIORITY_COLOR[item.priority]}>
|
||||||
|
{PRIORITY_LABEL[item.priority]}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={`${item.patient_name} · ${formatRelative(item.created_at)}`}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<ActionThreadDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
item={selectedItem}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
onActionComplete={handleActionComplete}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user