feat(miniprogram): 行动收件箱 — Service + 医生端列表页 + 半屏弹窗
- action-inbox.ts: listActionItems + getActionThread API 调用 - doctor/action-inbox: 待办列表页,Tab 筛选 + 半屏线程弹窗 + 操作按钮 - app.config.ts: 注册 action-inbox 页面到 doctor 子包
This commit is contained in:
@@ -27,6 +27,7 @@ export default defineAppConfig({
|
|||||||
'followup/index', 'followup/detail/index',
|
'followup/index', 'followup/detail/index',
|
||||||
'report/index', 'report/detail/index',
|
'report/index', 'report/detail/index',
|
||||||
'alerts/index', 'alerts/detail/index',
|
'alerts/index', 'alerts/detail/index',
|
||||||
|
'action-inbox/index',
|
||||||
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
|
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
|
||||||
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
|
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
|
||||||
],
|
],
|
||||||
|
|||||||
182
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal file
182
apps/miniprogram/src/pages/doctor/action-inbox/index.scss
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal file
228
apps/miniprogram/src/pages/doctor/action-inbox/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
apps/miniprogram/src/services/action-inbox.ts
Normal file
60
apps/miniprogram/src/services/action-inbox.ts
Normal 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user