feat(mp): 医护端告警列表/详情页 + DoctorHome 告警 banner 增强
- 新增告警列表页:按状态筛选、分页、严重程度/状态标签 - 新增告警详情页:完整信息展示 + 确认/忽略/恢复操作 - doctor.ts 新增 listAlerts/acknowledgeAlert/dismissAlert/resolveAlert API - DoctorHome 告警 banner 跳转目标改为告警列表页 - 注册 alerts/index + alerts/detail/index 到 doctor subPackage
This commit is contained in:
@@ -10,13 +10,12 @@ export default defineAppConfig({
|
|||||||
'pages/appointment/index',
|
'pages/appointment/index',
|
||||||
'pages/appointment/create/index',
|
'pages/appointment/create/index',
|
||||||
'pages/appointment/detail/index',
|
'pages/appointment/detail/index',
|
||||||
'pages/article/index',
|
|
||||||
'pages/legal/user-agreement',
|
'pages/legal/user-agreement',
|
||||||
'pages/legal/privacy-policy',
|
'pages/legal/privacy-policy',
|
||||||
],
|
],
|
||||||
subPackages: [
|
subPackages: [
|
||||||
{
|
{
|
||||||
root: 'pages/health',
|
root: 'pages/pkg-health',
|
||||||
pages: ['trend/index', 'input/index', 'daily-monitoring/index'],
|
pages: ['trend/index', 'input/index', 'daily-monitoring/index'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -26,26 +25,43 @@ export default defineAppConfig({
|
|||||||
'consultation/index', 'consultation/detail/index',
|
'consultation/index', 'consultation/detail/index',
|
||||||
'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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: 'pages/mall',
|
root: 'pages/pkg-mall',
|
||||||
pages: ['exchange/index', 'orders/index', 'detail/index'],
|
pages: ['exchange/index', 'orders/index', 'detail/index'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: 'pages/profile',
|
root: 'pages/pkg-profile',
|
||||||
pages: [
|
pages: [
|
||||||
'family/index', 'family-add/index', 'reports/index',
|
'family/index', 'family-add/index', 'reports/index',
|
||||||
'followups/index', 'medication/index', 'settings/index',
|
'followups/index', 'medication/index', 'settings/index',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: 'pages',
|
root: 'pages/ai-report',
|
||||||
pages: [
|
pages: ['list/index', 'detail/index'],
|
||||||
'article/detail/index', 'ai-report/list/index',
|
},
|
||||||
'ai-report/detail/index', 'report/detail/index',
|
{
|
||||||
'followup/detail/index', 'events/index', 'device-sync/index',
|
root: 'pages/article',
|
||||||
],
|
pages: ['index', 'detail/index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: 'pages/report',
|
||||||
|
pages: ['detail/index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: 'pages/followup',
|
||||||
|
pages: ['detail/index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: 'pages/events',
|
||||||
|
pages: ['index'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: 'pages/device-sync',
|
||||||
|
pages: ['index'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tabBar: {
|
tabBar: {
|
||||||
|
|||||||
166
apps/miniprogram/src/pages/doctor/alerts/detail/index.scss
Normal file
166
apps/miniprogram/src/pages/doctor/alerts/detail/index.scss
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
@import '../../../../styles/variables.scss';
|
||||||
|
@import '../../../../styles/mixins.scss';
|
||||||
|
|
||||||
|
.alert-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
padding: 24px;
|
||||||
|
padding-bottom: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-detail-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-severity {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background: $bd-l;
|
||||||
|
color: $tx2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background: $wrn-l;
|
||||||
|
color: $wrn;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--critical {
|
||||||
|
background: $dan-l;
|
||||||
|
color: $dan;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--urgent {
|
||||||
|
background: $dan-l;
|
||||||
|
color: $dan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-status {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: $wrn-l;
|
||||||
|
color: $wrn;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--acknowledged {
|
||||||
|
background: $pri-l;
|
||||||
|
color: $pri;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--resolved {
|
||||||
|
background: $acc-l;
|
||||||
|
color: $acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--dismissed {
|
||||||
|
background: $bd-l;
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-detail-card {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx2;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 28px;
|
||||||
|
color: $tx;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&--id {
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx3;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--detail {
|
||||||
|
font-size: 22px;
|
||||||
|
color: $tx2;
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: $bg;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: $r;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-action-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88px;
|
||||||
|
line-height: 88px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
background: $pri;
|
||||||
|
color: $card;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--default {
|
||||||
|
background: $bd-l;
|
||||||
|
color: $tx2;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--resolve {
|
||||||
|
background: $acc-l;
|
||||||
|
color: $acc;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
210
apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx
Normal file
210
apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, ScrollView, Button } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import * as doctorApi from '@/services/doctor';
|
||||||
|
import Loading from '@/components/Loading';
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
info: { label: '提示', className: 'detail-severity--info' },
|
||||||
|
warning: { label: '警告', className: 'detail-severity--warning' },
|
||||||
|
critical: { label: '严重', className: 'detail-severity--critical' },
|
||||||
|
urgent: { label: '紧急', className: 'detail-severity--urgent' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
pending: { label: '待处理', className: 'detail-status--pending' },
|
||||||
|
acknowledged: { label: '已确认', className: 'detail-status--acknowledged' },
|
||||||
|
resolved: { label: '已恢复', className: 'detail-status--resolved' },
|
||||||
|
dismissed: { label: '已忽略', className: 'detail-status--dismissed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AlertDetail() {
|
||||||
|
const [alert, setAlert] = useState<doctorApi.Alert | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = Taro.getCurrentInstance().router?.params;
|
||||||
|
if (params?.id) {
|
||||||
|
loadAlert(params.id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAlert = async (id: string) => {
|
||||||
|
try {
|
||||||
|
// 告警列表 API 支持按 ID 查询,此处用列表加载后过滤
|
||||||
|
const res = await doctorApi.listAlerts({ page: 1, page_size: 100 });
|
||||||
|
const found = (res.data || []).find((a) => a.id === id);
|
||||||
|
if (found) {
|
||||||
|
setAlert(found);
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: '告警不存在', icon: 'none' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAcknowledge = async () => {
|
||||||
|
if (!alert) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await doctorApi.acknowledgeAlert(alert.id, alert.version);
|
||||||
|
setAlert(updated);
|
||||||
|
Taro.showToast({ title: '已确认', icon: 'success' });
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = async () => {
|
||||||
|
if (!alert) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await doctorApi.dismissAlert(alert.id, alert.version);
|
||||||
|
setAlert(updated);
|
||||||
|
Taro.showToast({ title: '已忽略', icon: 'success' });
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
if (!alert) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const updated = await doctorApi.resolveAlert(alert.id, alert.version);
|
||||||
|
setAlert(updated);
|
||||||
|
Taro.showToast({ title: '已恢复', icon: 'success' });
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '操作失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <Loading />;
|
||||||
|
if (!alert) {
|
||||||
|
return (
|
||||||
|
<View className='alert-detail-page'>
|
||||||
|
<Text>告警不存在</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||||
|
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||||
|
const isPending = alert.status === 'pending';
|
||||||
|
const isAcknowledged = alert.status === 'acknowledged';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView scrollY className='alert-detail-page'>
|
||||||
|
{/* 顶部状态 */}
|
||||||
|
<View className='alert-detail-header'>
|
||||||
|
<View className='alert-detail-header__tags'>
|
||||||
|
<Text className={`detail-severity ${severity.className}`}>
|
||||||
|
{severity.label}
|
||||||
|
</Text>
|
||||||
|
<Text className={`detail-status ${status.className}`}>
|
||||||
|
{status.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='alert-detail-header__time'>
|
||||||
|
{new Date(alert.created_at).toLocaleString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 告警信息 */}
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>告警标题</Text>
|
||||||
|
<Text className='alert-detail-card__value'>{alert.title}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>患者 ID</Text>
|
||||||
|
<Text className='alert-detail-card__value alert-detail-card__value--id'>
|
||||||
|
{alert.patient_id.slice(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>严重程度</Text>
|
||||||
|
<Text className='alert-detail-card__value'>{severity.label}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{alert.detail && (
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>告警详情</Text>
|
||||||
|
<Text className='alert-detail-card__value alert-detail-card__value--detail'>
|
||||||
|
{JSON.stringify(alert.detail, null, 2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert.acknowledged_by && (
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>处理人</Text>
|
||||||
|
<Text className='alert-detail-card__value'>{alert.acknowledged_by}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert.acknowledged_at && (
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>确认时间</Text>
|
||||||
|
<Text className='alert-detail-card__value'>
|
||||||
|
{new Date(alert.acknowledged_at).toLocaleString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{alert.resolved_at && (
|
||||||
|
<View className='alert-detail-card'>
|
||||||
|
<Text className='alert-detail-card__label'>恢复时间</Text>
|
||||||
|
<Text className='alert-detail-card__value'>
|
||||||
|
{new Date(alert.resolved_at).toLocaleString('zh-CN')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{(isPending || isAcknowledged) && (
|
||||||
|
<View className='alert-detail-actions'>
|
||||||
|
{isPending && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className='alert-action-btn alert-action-btn--primary'
|
||||||
|
onClick={handleAcknowledge}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className='alert-action-btn alert-action-btn--default'
|
||||||
|
onClick={handleDismiss}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
忽略
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(isPending || isAcknowledged) && (
|
||||||
|
<Button
|
||||||
|
className='alert-action-btn alert-action-btn--resolve'
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={actionLoading}
|
||||||
|
>
|
||||||
|
恢复
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/miniprogram/src/pages/doctor/alerts/index.scss
Normal file
174
apps/miniprogram/src/pages/doctor/alerts/index.scss
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
@import '../../../styles/variables.scss';
|
||||||
|
@import '../../../styles/mixins.scss';
|
||||||
|
|
||||||
|
.alert-list-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
padding: 24px;
|
||||||
|
padding-bottom: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-list-title {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-list-count {
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-tab {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: $r-pill;
|
||||||
|
background: $bd-l;
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx2;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: $pri;
|
||||||
|
color: $card;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-card {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r-lg;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: $shadow-sm;
|
||||||
|
border-left: 4px solid $wrn;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: $bd-l;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--critical {
|
||||||
|
border-left-color: $dan;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
border-left-color: $tx3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $tx;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 22px;
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-severity {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background: $bd-l;
|
||||||
|
color: $tx2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background: $wrn-l;
|
||||||
|
color: $wrn;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--critical {
|
||||||
|
background: $dan-l;
|
||||||
|
color: $dan;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--urgent {
|
||||||
|
background: $dan-l;
|
||||||
|
color: $dan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-status {
|
||||||
|
font-size: 22px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: $r-sm;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: $wrn-l;
|
||||||
|
color: $wrn;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--acknowledged {
|
||||||
|
background: $pri-l;
|
||||||
|
color: $pri;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--resolved {
|
||||||
|
background: $acc-l;
|
||||||
|
color: $acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--dismissed {
|
||||||
|
background: $bd-l;
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 32px;
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
font-size: 26px;
|
||||||
|
color: $pri;
|
||||||
|
padding: 12px 24px;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
color: $tx3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
font-size: 24px;
|
||||||
|
color: $tx2;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
apps/miniprogram/src/pages/doctor/alerts/index.tsx
Normal file
152
apps/miniprogram/src/pages/doctor/alerts/index.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, ScrollView } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import * as doctorApi from '@/services/doctor';
|
||||||
|
import Loading from '@/components/Loading';
|
||||||
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import './index.scss';
|
||||||
|
|
||||||
|
const SEVERITY_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
info: { label: '提示', className: 'alert-severity--info' },
|
||||||
|
warning: { label: '警告', className: 'alert-severity--warning' },
|
||||||
|
critical: { label: '严重', className: 'alert-severity--critical' },
|
||||||
|
urgent: { label: '紧急', className: 'alert-severity--urgent' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||||
|
pending: { label: '待处理', className: 'alert-status--pending' },
|
||||||
|
acknowledged: { label: '已确认', className: 'alert-status--acknowledged' },
|
||||||
|
resolved: { label: '已恢复', className: 'alert-status--resolved' },
|
||||||
|
dismissed: { label: '已忽略', className: 'alert-status--dismissed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TABS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'pending', label: '待处理' },
|
||||||
|
{ value: 'acknowledged', label: '已确认' },
|
||||||
|
{ value: 'resolved', label: '已恢复' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AlertList() {
|
||||||
|
const [alerts, setAlerts] = useState<doctorApi.Alert[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [activeTab, setActiveTab] = useState('');
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlerts();
|
||||||
|
}, [page, activeTab]);
|
||||||
|
|
||||||
|
const loadAlerts = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await doctorApi.listAlerts({
|
||||||
|
status: activeTab || undefined,
|
||||||
|
page,
|
||||||
|
page_size: 20,
|
||||||
|
});
|
||||||
|
setAlerts(res.data || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch {
|
||||||
|
Taro.showToast({ title: '加载失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (value: string) => {
|
||||||
|
setActiveTab(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlertClick = (alert: doctorApi.Alert) => {
|
||||||
|
Taro.navigateTo({ url: `/pages/doctor/alerts/detail/index?id=${alert.id}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - d.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return '刚刚';
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && alerts.length === 0) return <Loading />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView scrollY className='alert-list-page'>
|
||||||
|
<View className='alert-list-header'>
|
||||||
|
<Text className='alert-list-title'>告警列表</Text>
|
||||||
|
<Text className='alert-list-count'>共 {total} 条</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='alert-tabs'>
|
||||||
|
{STATUS_TABS.map((tab) => (
|
||||||
|
<Text
|
||||||
|
key={tab.value}
|
||||||
|
className={`alert-tab ${activeTab === tab.value ? 'alert-tab--active' : ''}`}
|
||||||
|
onClick={() => handleTabChange(tab.value)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<EmptyState description='暂无告警' />
|
||||||
|
) : (
|
||||||
|
<View className='alert-cards'>
|
||||||
|
{alerts.map((alert) => {
|
||||||
|
const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info;
|
||||||
|
const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending;
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={alert.id}
|
||||||
|
className='alert-card'
|
||||||
|
onClick={() => handleAlertClick(alert)}
|
||||||
|
>
|
||||||
|
<View className='alert-card__header'>
|
||||||
|
<Text className={`alert-severity ${severity.className}`}>
|
||||||
|
{severity.label}
|
||||||
|
</Text>
|
||||||
|
<Text className={`alert-status ${status.className}`}>
|
||||||
|
{status.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='alert-card__title'>{alert.title}</Text>
|
||||||
|
<View className='alert-card__footer'>
|
||||||
|
<Text className='alert-card__time'>{formatTime(alert.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{total > 20 && (
|
||||||
|
<View className='alert-pagination'>
|
||||||
|
<Text
|
||||||
|
className={`alert-pagination__btn ${page <= 1 ? 'disabled' : ''}`}
|
||||||
|
onClick={() => page > 1 && setPage(page - 1)}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Text>
|
||||||
|
<Text className='alert-pagination__info'>
|
||||||
|
{page} / {Math.ceil(total / 20)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={`alert-pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
|
||||||
|
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ export default function DoctorHome() {
|
|||||||
<View className='doctor-home__alert'>
|
<View className='doctor-home__alert'>
|
||||||
<Text className='doctor-home__alert-icon'>!</Text>
|
<Text className='doctor-home__alert-icon'>!</Text>
|
||||||
<Text className='doctor-home__alert-text'>{alertCount} 位患者体征异常</Text>
|
<Text className='doctor-home__alert-text'>{alertCount} 位患者体征异常</Text>
|
||||||
<Text className='doctor-home__alert-link' onClick={() => Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>查看 →</Text>
|
<Text className='doctor-home__alert-link' onClick={() => Taro.navigateTo({ url: '/pages/doctor/alerts/index' })}>查看 →</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -304,3 +304,41 @@ export async function getConsultationStats() {
|
|||||||
export async function getFollowUpStats() {
|
export async function getFollowUpStats() {
|
||||||
return api.get<FollowUpStats>('/health/admin/statistics/follow-ups');
|
return api.get<FollowUpStats>('/health/admin/statistics/follow-ups');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Alerts (doctor view) ────────────────────────────
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
rule_id: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
acknowledged_by?: string;
|
||||||
|
acknowledged_at?: string;
|
||||||
|
resolved_at?: string;
|
||||||
|
created_at: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAlerts(params?: {
|
||||||
|
patient_id?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}) {
|
||||||
|
return api.get<{ data: Alert[]; total: number }>('/health/alerts', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acknowledgeAlert(id: string, version: number) {
|
||||||
|
return api.put<Alert>(`/health/alerts/${id}/acknowledge`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissAlert(id: string, version: number) {
|
||||||
|
return api.put<Alert>(`/health/alerts/${id}/dismiss`, { version });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAlert(id: string, version: number) {
|
||||||
|
return api.put<Alert>(`/health/alerts/${id}/resolve`, { version });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user