From 10c79c5e392cf29b5202d023a3fc1658965104df Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 28 Apr 2026 20:05:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mp):=20=E5=8C=BB=E6=8A=A4=E7=AB=AF?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E5=88=97=E8=A1=A8/=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=20+=20DoctorHome=20=E5=91=8A=E8=AD=A6=20banner=20?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增告警列表页:按状态筛选、分页、严重程度/状态标签 - 新增告警详情页:完整信息展示 + 确认/忽略/恢复操作 - doctor.ts 新增 listAlerts/acknowledgeAlert/dismissAlert/resolveAlert API - DoctorHome 告警 banner 跳转目标改为告警列表页 - 注册 alerts/index + alerts/detail/index 到 doctor subPackage --- apps/miniprogram/src/app.config.ts | 36 ++- .../src/pages/doctor/alerts/detail/index.scss | 166 ++++++++++++++ .../src/pages/doctor/alerts/detail/index.tsx | 210 ++++++++++++++++++ .../src/pages/doctor/alerts/index.scss | 174 +++++++++++++++ .../src/pages/doctor/alerts/index.tsx | 152 +++++++++++++ apps/miniprogram/src/pages/doctor/index.tsx | 2 +- apps/miniprogram/src/services/doctor.ts | 38 ++++ 7 files changed, 767 insertions(+), 11 deletions(-) create mode 100644 apps/miniprogram/src/pages/doctor/alerts/detail/index.scss create mode 100644 apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx create mode 100644 apps/miniprogram/src/pages/doctor/alerts/index.scss create mode 100644 apps/miniprogram/src/pages/doctor/alerts/index.tsx diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 35ad8fb..ee44ed9 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -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: { diff --git a/apps/miniprogram/src/pages/doctor/alerts/detail/index.scss b/apps/miniprogram/src/pages/doctor/alerts/detail/index.scss new file mode 100644 index 0000000..18e7202 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/alerts/detail/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx b/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx new file mode 100644 index 0000000..ecaec99 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx @@ -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 = { + 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 = { + 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(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 ; + if (!alert) { + return ( + + 告警不存在 + + ); + } + + 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 ( + + {/* 顶部状态 */} + + + + {severity.label} + + + {status.label} + + + + {new Date(alert.created_at).toLocaleString('zh-CN')} + + + + {/* 告警信息 */} + + 告警标题 + {alert.title} + + + + 患者 ID + + {alert.patient_id.slice(0, 8)}... + + + + + 严重程度 + {severity.label} + + + {alert.detail && ( + + 告警详情 + + {JSON.stringify(alert.detail, null, 2)} + + + )} + + {alert.acknowledged_by && ( + + 处理人 + {alert.acknowledged_by} + + )} + + {alert.acknowledged_at && ( + + 确认时间 + + {new Date(alert.acknowledged_at).toLocaleString('zh-CN')} + + + )} + + {alert.resolved_at && ( + + 恢复时间 + + {new Date(alert.resolved_at).toLocaleString('zh-CN')} + + + )} + + {/* 操作按钮 */} + {(isPending || isAcknowledged) && ( + + {isPending && ( + <> + + + + )} + {(isPending || isAcknowledged) && ( + + )} + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/doctor/alerts/index.scss b/apps/miniprogram/src/pages/doctor/alerts/index.scss new file mode 100644 index 0000000..13ffa62 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/alerts/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/doctor/alerts/index.tsx b/apps/miniprogram/src/pages/doctor/alerts/index.tsx new file mode 100644 index 0000000..947b420 --- /dev/null +++ b/apps/miniprogram/src/pages/doctor/alerts/index.tsx @@ -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 = { + 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 = { + 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([]); + 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 ; + + return ( + + + 告警列表 + 共 {total} 条 + + + + {STATUS_TABS.map((tab) => ( + handleTabChange(tab.value)} + > + {tab.label} + + ))} + + + {alerts.length === 0 ? ( + + ) : ( + + {alerts.map((alert) => { + const severity = SEVERITY_MAP[alert.severity] ?? SEVERITY_MAP.info; + const status = STATUS_MAP[alert.status] ?? STATUS_MAP.pending; + return ( + handleAlertClick(alert)} + > + + + {severity.label} + + + {status.label} + + + {alert.title} + + {formatTime(alert.created_at)} + + + ); + })} + + )} + + {total > 20 && ( + + page > 1 && setPage(page - 1)} + > + 上一页 + + + {page} / {Math.ceil(total / 20)} + + = Math.ceil(total / 20) ? 'disabled' : ''}`} + onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)} + > + 下一页 + + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/doctor/index.tsx b/apps/miniprogram/src/pages/doctor/index.tsx index 4a8a3ed..25b0723 100644 --- a/apps/miniprogram/src/pages/doctor/index.tsx +++ b/apps/miniprogram/src/pages/doctor/index.tsx @@ -88,7 +88,7 @@ export default function DoctorHome() { ! {alertCount} 位患者体征异常 - Taro.navigateTo({ url: '/pages/doctor/patients/index' })}>查看 → + Taro.navigateTo({ url: '/pages/doctor/alerts/index' })}>查看 → )} diff --git a/apps/miniprogram/src/services/doctor.ts b/apps/miniprogram/src/services/doctor.ts index 4abd721..98f9fa5 100644 --- a/apps/miniprogram/src/services/doctor.ts +++ b/apps/miniprogram/src/services/doctor.ts @@ -304,3 +304,41 @@ export async function getConsultationStats() { export async function getFollowUpStats() { return api.get('/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; + 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(`/health/alerts/${id}/acknowledge`, { version }); +} + +export async function dismissAlert(id: string, version: number) { + return api.put(`/health/alerts/${id}/dismiss`, { version }); +} + +export async function resolveAlert(id: string, version: number) { + return api.put(`/health/alerts/${id}/resolve`, { version }); +}