diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8f0c383..03eb7ce 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -48,6 +48,8 @@ const AlertList = lazy(() => import('./pages/health/AlertList')); const AlertDashboard = lazy(() => import('./pages/health/AlertDashboard')); const AlertRuleList = lazy(() => import('./pages/health/AlertRuleList')); 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')); @@ -254,6 +256,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* 内容管理 */} } /> } /> diff --git a/apps/web/src/api/health/actionInbox.ts b/apps/web/src/api/health/actionInbox.ts new file mode 100644 index 0000000..ca92bc7 --- /dev/null +++ b/apps/web/src/api/health/actionInbox.ts @@ -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; + }>('/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; + }, +}; diff --git a/apps/web/src/components/ActionThreadDrawer.tsx b/apps/web/src/components/ActionThreadDrawer.tsx new file mode 100644 index 0000000..0f986c9 --- /dev/null +++ b/apps/web/src/components/ActionThreadDrawer.tsx @@ -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 = { + urgent: 'red', + high: 'orange', + medium: 'blue', + low: 'default', +}; + +const PRIORITY_LABEL: Record = { + 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(null); + const [threadData, setThreadData] = useState(null); + const [actionLoading, setActionLoading] = useState(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 ; + case 'in_progress': + return ; + case 'dismissed': + return ; + default: + return ; + } + }; + + return ( + + {loading && ( +
+ +
+ )} + + {error && ( +
+ +
+ )} + + {threadData && !loading && ( + <> +
+
+ {threadData.action_item.title} +
+
+ {threadData.action_item.patient_name} + + {PRIORITY_LABEL[threadData.action_item.priority]} + +
+
+ +
+
+ 处理进度 +
+ ({ + color: + evt.status === 'completed' + ? 'green' + : evt.status === 'in_progress' + ? 'blue' + : 'gray', + dot: timelineDot(evt.status), + children: ( +
+
+ {evt.label} +
+ {evt.detail && ( +
+ {evt.detail} +
+ )} + {evt.timestamp && ( +
+ {new Date(evt.timestamp).toLocaleString('zh-CN')} +
+ )} + {evt.link_to && ( + handleLinkClick(evt.link_to)} + style={{ fontSize: 12 }} + > + 查看详情 → + + )} +
+ ), + }))} + /> +
+ + {threadData.available_actions.length > 0 && ( +
+ + {threadData.available_actions.map((action) => ( + + ))} + +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/pages/health/ActionInbox.tsx b/apps/web/src/pages/health/ActionInbox.tsx new file mode 100644 index 0000000..254fed7 --- /dev/null +++ b/apps/web/src/pages/health/ActionInbox.tsx @@ -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 = { + ai_suggestion: { label: 'AI建议', color: '#722ed1' }, + alert: { label: '告警', color: '#f5222d' }, + followup: { label: '随访', color: '#1890ff' }, + data_anomaly: { label: '异常', color: '#fa8c16' }, +}; + +const PRIORITY_LABEL: Record = { + urgent: '紧急', + high: '高', + medium: '中', + low: '低', +}; + +const PRIORITY_COLOR: Record = { + 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 = { + pending: 'error', + in_progress: 'processing', + completed: 'default', +}; + +export default function ActionInbox() { + const [items, setItems] = useState([]); + 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(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 ( + + ({ + key: tab.key, + label: tab.label, + }))} + /> + + + {items.length === 0 && !loading ? ( + + ) : ( + fetchData(p, statusFilter), + showTotal: (t) => `共 ${t} 条`, + }} + renderItem={(item) => { + const typeConf = + TYPE_CONFIG[item.action_type] ?? + ({ label: '未知', color: '#999' } as { + label: string; + color: string; + }); + return ( + handleItemClick(item)} + > + + } + title={ +
+ {typeConf.label} + {item.title} + + {PRIORITY_LABEL[item.priority]} + +
+ } + description={`${item.patient_name} · ${formatRelative(item.created_at)}`} + /> +
+ ); + }} + /> + )} +
+ + setDrawerOpen(false)} + onActionComplete={handleActionComplete} + /> +
+ ); +}