feat(miniprogram): 行动收件箱 — Service + 医生端列表页 + 半屏弹窗
- action-inbox.ts: listActionItems + getActionThread API 调用 - doctor/action-inbox: 待办列表页,Tab 筛选 + 半屏线程弹窗 + 操作按钮 - app.config.ts: 注册 action-inbox 页面到 doctor 子包
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user