diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 167c029..3c5e2d1 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -27,6 +27,7 @@ export default defineAppConfig({ 'followup/index', 'followup/detail/index', 'report/index', 'report/detail/index', 'alerts/index', 'alerts/detail/index', + 'action-inbox/index', 'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index', 'prescription/index', 'prescription/detail/index', 'prescription/create/index', ], diff --git a/apps/miniprogram/src/pages/doctor/action-inbox/index.scss b/apps/miniprogram/src/pages/doctor/action-inbox/index.scss new file mode 100644 index 0000000..16d1fa5 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/action-inbox/index.scss @@ -0,0 +1,182 @@ +.action-inbox-page { + min-height: 100vh; + background: #f5f5f5; +} + +.inbox-tabs { + display: flex; + background: white; + padding: 0 16px; + border-bottom: 1px solid #eee; + + .inbox-tab { + flex: 1; + text-align: center; + padding: 12px 0; + + &.active { + .inbox-tab-text { + color: #C4623A; + font-weight: 600; + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: -12px; + left: 30%; + right: 30%; + height: 3px; + background: #C4623A; + border-radius: 2px; + } + } + } + } + + .inbox-tab-text { + font-size: 14px; + color: #666; + } +} + +.inbox-list { + height: calc(100vh - 50px); + padding: 12px; +} + +.inbox-card { + background: white; + border-radius: 12px; + padding: 14px 16px; + margin-bottom: 10px; + + .inbox-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + + .inbox-type-tag { + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; + } + + .inbox-card-title { + font-size: 14px; + font-weight: 500; + } + + .inbox-card-desc { + font-size: 12px; + color: #999; + } +} + +.inbox-empty { + text-align: center; + padding: 80px 0; + + .inbox-empty-text { + font-size: 14px; + color: #999; + } +} + +// 半屏弹窗 +.half-screen-dialog { + position: fixed; + bottom: 0; + left: 0; + right: 0; + max-height: 70vh; + background: white; + border-radius: 16px 16px 0 0; + z-index: 1000; + overflow-y: auto; + + .dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #f0f0f0; + + .dialog-title { + font-size: 16px; + font-weight: 600; + } + + .dialog-close { + font-size: 13px; + color: #999; + } + } + + .dialog-body { + padding: 16px 20px; + } + + .dialog-patient { + font-size: 13px; + color: #666; + display: block; + margin-bottom: 12px; + } + + .thread-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 6px 0; + } + + .thread-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 4px; + flex-shrink: 0; + + &.completed { background: #52c41a; } + &.in_progress { background: #faad14; } + &.pending { background: #d9d9d9; } + &.dismissed { background: #ff4d4f; } + } + + .thread-content { + .thread-label { + font-size: 13px; + display: block; + } + + .thread-time { + font-size: 11px; + color: #999; + } + } + + .dialog-actions { + display: flex; + gap: 8px; + padding: 12px 20px 20px; + border-top: 1px solid #f0f0f0; + + .action-btn { + flex: 1; + text-align: center; + padding: 10px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + + &.primary { background: #C4623A; color: white; } + &.danger { background: #ff4d4f; color: white; } + &.default { background: #f5f5f5; color: #666; } + } + } +} diff --git a/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx b/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx new file mode 100644 index 0000000..311e444 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx @@ -0,0 +1,228 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { View, Text, ScrollView } from '@tarojs/components'; +import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import { + listActionItems, + getActionThread, + type ActionItem, + type ThreadResponse, +} from '@/services/action-inbox'; +import Loading from '@/components/Loading'; +import './index.scss'; + +const TYPE_LABEL: Record = { + ai_suggestion: 'AI建议', + alert: '告警', + followup: '随访', + data_anomaly: '异常', +}; + +const TYPE_COLOR: Record = { + ai_suggestion: '#722ed1', + alert: '#f5222d', + followup: '#1890ff', + data_anomaly: '#fa8c16', +}; + +const STATUS_TABS = [ + { key: '', label: '全部' }, + { key: 'pending', label: '待处理' }, + { key: 'in_progress', label: '进行中' }, + { key: 'completed', label: '已完成' }, +]; + +export default function ActionInboxPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [_page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState(''); + const [threadData, setThreadData] = useState(null); + const [showDetail, setShowDetail] = useState(false); + const loadingRef = useRef(false); + + const fetchItems = useCallback( + async (pageNum: number, status: string, isRefresh = false) => { + if (loadingRef.current) return; + loadingRef.current = true; + setLoading(true); + try { + const resp = await listActionItems({ + page: pageNum, + page_size: 20, + status: status || undefined, + }); + const list = resp.data || []; + if (isRefresh) { + setItems(list); + } else { + setItems((prev) => [...prev, ...list]); + } + setTotal(resp.total); + setPage(pageNum); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setLoading(false); + loadingRef.current = false; + } + }, + [], + ); + + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '待办事项' }); + fetchItems(1, activeTab, true); + }); + + usePullDownRefresh(() => { + fetchItems(1, activeTab, true).then(() => + Taro.stopPullDownRefresh(), + ); + }); + + const handleTabChange = (key: string) => { + setActiveTab(key); + fetchItems(1, key, true); + }; + + const handleItemClick = async (item: ActionItem) => { + try { + const data = await getActionThread(item.source_ref); + setThreadData(data); + setShowDetail(true); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } + }; + + const handleAction = async (action: { + key: string; + api_endpoint?: string; + }) => { + if (!action.api_endpoint || !threadData) return; + try { + await Taro.request({ + url: `${process.env.TARO_APP_API_URL}${action.api_endpoint}`, + method: 'POST', + header: { 'Content-Type': 'application/json' }, + data: { action: action.key }, + }); + Taro.showToast({ title: '操作成功', icon: 'success' }); + setShowDetail(false); + fetchItems(1, activeTab, true); + } catch { + Taro.showToast({ title: '操作失败', icon: 'none' }); + } + }; + + return ( + + + {STATUS_TABS.map((tab) => ( + handleTabChange(tab.key)} + > + + {tab.label} + + + ))} + + + {items.length === 0 && !loading ? ( + + 暂无待办事项 + + ) : ( + + {items.map((item) => ( + handleItemClick(item)} + > + + + {TYPE_LABEL[item.action_type] || '未知'} + + {item.title} + + + {item.patient_name} ·{' '} + {new Date(item.created_at).toLocaleDateString('zh-CN')} + + + ))} + {loading && } + {!loading && items.length >= total && total > 0 && ( + + )} + + )} + + {showDetail && threadData && ( + + + + {threadData.action_item.title} + + setShowDetail(false)} + > + 收起 + + + + + {threadData.action_item.patient_name} ·{' '} + {threadData.action_item.priority === 'urgent' + ? '紧急' + : threadData.action_item.priority === 'high' + ? '高' + : '中'} + + + {threadData.thread.map((evt, idx) => ( + + + + {evt.label} + {evt.timestamp && ( + + {new Date(evt.timestamp).toLocaleDateString('zh-CN')} + + )} + + + ))} + + + {threadData.available_actions.length > 0 && ( + + {threadData.available_actions.map((action) => ( + handleAction(action)} + > + {action.label} + + ))} + + )} + + )} + + ); +} diff --git a/apps/miniprogram/src/services/action-inbox.ts b/apps/miniprogram/src/services/action-inbox.ts new file mode 100644 index 0000000..83cd5d3 --- /dev/null +++ b/apps/miniprogram/src/services/action-inbox.ts @@ -0,0 +1,60 @@ +import { api } from './request'; + +export interface ActionItem { + id: string; + action_type: string; + priority: string; + status: string; + 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: string; + detail?: string; + timestamp?: string; + link_to?: string; +} + +export interface ActionDef { + key: string; + label: string; + variant: string; + api_endpoint?: string; +} + +export interface ThreadResponse { + action_item: ActionItem; + thread: ThreadEvent[]; + available_actions: ActionDef[]; +} + +interface PaginatedData { + data: ActionItem[]; + total: number; +} + +export async function listActionItems(params?: { + status?: string; + type?: string; + page?: number; + page_size?: number; +}) { + return api.get( + '/health/action-inbox', + params as Record, + ); +} + +export async function getActionThread(sourceRef: string) { + return api.get( + `/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`, + ); +}