feat(miniprogram): 行动收件箱 — Service + 医生端列表页 + 半屏弹窗
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- action-inbox.ts: listActionItems + getActionThread API 调用
- doctor/action-inbox: 待办列表页,Tab 筛选 + 半屏线程弹窗 + 操作按钮
- app.config.ts: 注册 action-inbox 页面到 doctor 子包
This commit is contained in:
iven
2026-05-01 16:40:32 +08:00
parent 6d66a392db
commit 75bf900950
4 changed files with 471 additions and 0 deletions

View File

@@ -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',
],

View File

@@ -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; }
}
}
}

View File

@@ -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<string, string> = {
ai_suggestion: 'AI建议',
alert: '告警',
followup: '随访',
data_anomaly: '异常',
};
const TYPE_COLOR: Record<string, string> = {
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<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [_page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('');
const [threadData, setThreadData] = useState<ThreadResponse | null>(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 (
<View className="action-inbox-page">
<View className="inbox-tabs">
{STATUS_TABS.map((tab) => (
<View
key={tab.key}
className={`inbox-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text
className={`inbox-tab-text ${activeTab === tab.key ? 'active' : ''}`}
>
{tab.label}
</Text>
</View>
))}
</View>
{items.length === 0 && !loading ? (
<View className="inbox-empty">
<Text className="inbox-empty-text"></Text>
</View>
) : (
<ScrollView scrollY className="inbox-list">
{items.map((item) => (
<View
key={item.id}
className="inbox-card"
onClick={() => handleItemClick(item)}
>
<View className="inbox-card-header">
<Text
className="inbox-type-tag"
style={{
background: TYPE_COLOR[item.action_type] || '#999',
}}
>
{TYPE_LABEL[item.action_type] || '未知'}
</Text>
<Text className="inbox-card-title">{item.title}</Text>
</View>
<Text className="inbox-card-desc">
{item.patient_name} ·{' '}
{new Date(item.created_at).toLocaleDateString('zh-CN')}
</Text>
</View>
))}
{loading && <Loading />}
{!loading && items.length >= total && total > 0 && (
<Loading text="没有更多了" />
)}
</ScrollView>
)}
{showDetail && threadData && (
<View className="half-screen-dialog">
<View className="dialog-header">
<Text className="dialog-title">
{threadData.action_item.title}
</Text>
<Text
className="dialog-close"
onClick={() => setShowDetail(false)}
>
</Text>
</View>
<View className="dialog-body">
<Text className="dialog-patient">
{threadData.action_item.patient_name} ·{' '}
{threadData.action_item.priority === 'urgent'
? '紧急'
: threadData.action_item.priority === 'high'
? '高'
: '中'}
</Text>
<View className="thread-timeline">
{threadData.thread.map((evt, idx) => (
<View key={idx} className="thread-item">
<View className={`thread-dot ${evt.status}`} />
<View className="thread-content">
<Text className="thread-label">{evt.label}</Text>
{evt.timestamp && (
<Text className="thread-time">
{new Date(evt.timestamp).toLocaleDateString('zh-CN')}
</Text>
)}
</View>
</View>
))}
</View>
</View>
{threadData.available_actions.length > 0 && (
<View className="dialog-actions">
{threadData.available_actions.map((action) => (
<View
key={action.key}
className={`action-btn ${action.variant}`}
onClick={() => handleAction(action)}
>
{action.label}
</View>
))}
</View>
)}
</View>
)}
</View>
);
}

View File

@@ -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<PaginatedData>(
'/health/action-inbox',
params as Record<string, string | number | undefined>,
);
}
export async function getActionThread(sourceRef: string) {
return api.get<ThreadResponse>(
`/health/action-inbox/${encodeURIComponent(sourceRef)}/thread`,
);
}