feat(mp): 医护端告警列表/详情页 + DoctorHome 告警 banner 增强
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

- 新增告警列表页:按状态筛选、分页、严重程度/状态标签
- 新增告警详情页:完整信息展示 + 确认/忽略/恢复操作
- doctor.ts 新增 listAlerts/acknowledgeAlert/dismissAlert/resolveAlert API
- DoctorHome 告警 banner 跳转目标改为告警列表页
- 注册 alerts/index + alerts/detail/index 到 doctor subPackage
This commit is contained in:
iven
2026-04-28 20:05:55 +08:00
parent 1cf5f59d8c
commit 10c79c5e39
7 changed files with 767 additions and 11 deletions

View File

@@ -10,13 +10,12 @@ export default defineAppConfig({
'pages/appointment/index',
'pages/appointment/create/index',
'pages/appointment/detail/index',
'pages/article/index',
'pages/legal/user-agreement',
'pages/legal/privacy-policy',
],
subPackages: [
{
root: 'pages/health',
root: 'pages/pkg-health',
pages: ['trend/index', 'input/index', 'daily-monitoring/index'],
},
{
@@ -26,26 +25,43 @@ export default defineAppConfig({
'consultation/index', 'consultation/detail/index',
'followup/index', 'followup/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'],
},
{
root: 'pages/profile',
root: 'pages/pkg-profile',
pages: [
'family/index', 'family-add/index', 'reports/index',
'followups/index', 'medication/index', 'settings/index',
],
},
{
root: 'pages',
pages: [
'article/detail/index', 'ai-report/list/index',
'ai-report/detail/index', 'report/detail/index',
'followup/detail/index', 'events/index', 'device-sync/index',
],
root: 'pages/ai-report',
pages: ['list/index', 'detail/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: {

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

View 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>
);
}

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

View 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>
);
}

View File

@@ -88,7 +88,7 @@ export default function DoctorHome() {
<View className='doctor-home__alert'>
<Text className='doctor-home__alert-icon'>!</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 File

@@ -304,3 +304,41 @@ export async function getConsultationStats() {
export async function getFollowUpStats() {
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 });
}