From 6296ce22d284b7a93d1a648668e2d629566f054c Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 25 Apr 2026 00:40:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=81=A5=E5=BA=B7=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=80=9A=E7=94=A8=E7=BB=84=E4=BB=B6=208=20=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusTag: 通用状态标签(预约/随访/咨询/患者状态) - PatientSelect: 患者远程搜索选择器 - DoctorSelect: 医护远程搜索选择器 - VitalSignsChart: ECharts 趋势图(可切换指标) - CalendarView: 日历视图(排班展示) - ChatBubble: 聊天气泡(角色区分左右布局) - ImagePreview: 图片预览(Ant Design Image.PreviewGroup) - ExportButton: 导出按钮(blob 下载) --- .../pages/health/components/CalendarView.tsx | 42 +++++++++++ .../pages/health/components/ChatBubble.tsx | 74 +++++++++++++++++++ .../pages/health/components/DoctorSelect.tsx | 55 ++++++++++++++ .../pages/health/components/ExportButton.tsx | 45 +++++++++++ .../pages/health/components/ImagePreview.tsx | 25 +++++++ .../pages/health/components/PatientSelect.tsx | 55 ++++++++++++++ .../src/pages/health/components/StatusTag.tsx | 30 ++++++++ .../health/components/VitalSignsChart.tsx | 55 ++++++++++++++ 8 files changed, 381 insertions(+) create mode 100644 apps/web/src/pages/health/components/CalendarView.tsx create mode 100644 apps/web/src/pages/health/components/ChatBubble.tsx create mode 100644 apps/web/src/pages/health/components/DoctorSelect.tsx create mode 100644 apps/web/src/pages/health/components/ExportButton.tsx create mode 100644 apps/web/src/pages/health/components/ImagePreview.tsx create mode 100644 apps/web/src/pages/health/components/PatientSelect.tsx create mode 100644 apps/web/src/pages/health/components/StatusTag.tsx create mode 100644 apps/web/src/pages/health/components/VitalSignsChart.tsx diff --git a/apps/web/src/pages/health/components/CalendarView.tsx b/apps/web/src/pages/health/components/CalendarView.tsx new file mode 100644 index 0000000..1d272c0 --- /dev/null +++ b/apps/web/src/pages/health/components/CalendarView.tsx @@ -0,0 +1,42 @@ +import { Calendar, Badge } from 'antd'; +import type { Dayjs } from 'dayjs'; + +export interface ScheduleItem { + id: string; + doctor_name?: string; + start_time: string; + end_time: string; + current_appointments: number; + max_appointments: number; + status: string; +} + +interface Props { + schedules: Record; +} + +export function CalendarView({ schedules }: Props) { + const cellRender = (date: Dayjs) => { + const key = date.format('YYYY-MM-DD'); + const items = schedules[key]; + if (!items || items.length === 0) return null; + + return ( + + ); + }; + + return ; +} diff --git a/apps/web/src/pages/health/components/ChatBubble.tsx b/apps/web/src/pages/health/components/ChatBubble.tsx new file mode 100644 index 0000000..fde1913 --- /dev/null +++ b/apps/web/src/pages/health/components/ChatBubble.tsx @@ -0,0 +1,74 @@ +import { Avatar, Typography, Space } from 'antd'; +import { UserOutlined } from '@ant-design/icons'; + +interface Props { + senderRole: 'patient' | 'doctor' | 'system'; + senderName?: string; + content: string; + contentType?: string; + createdAt: string; +} + +const ROLE_CONFIG = { + patient: { align: 'flex-start' as const, bg: '#f0f0f0', color: '#000' }, + doctor: { align: 'flex-end' as const, bg: '#1890ff', color: '#fff' }, + system: { align: 'center' as const, bg: '#fafafa', color: '#999' }, +}; + +export function ChatBubble({ + senderRole, + senderName, + content, + createdAt, +}: Props) { + const cfg = ROLE_CONFIG[senderRole] ?? ROLE_CONFIG.system; + + if (senderRole === 'system') { + return ( +
+ + {content} + +
+ ); + } + + return ( +
+ {senderRole === 'patient' && ( + } style={{ marginRight: 8, flexShrink: 0 }} /> + )} +
+ {senderName && ( + + {senderName} + + )} +
+ + {content} + +
+ + {createdAt} + +
+ {senderRole === 'doctor' && ( + } style={{ marginLeft: 8, flexShrink: 0 }} /> + )} +
+ ); +} diff --git a/apps/web/src/pages/health/components/DoctorSelect.tsx b/apps/web/src/pages/health/components/DoctorSelect.tsx new file mode 100644 index 0000000..9fd2f0f --- /dev/null +++ b/apps/web/src/pages/health/components/DoctorSelect.tsx @@ -0,0 +1,55 @@ +import { Select } from 'antd'; +import { useState, useCallback } from 'react'; +import { doctorApi } from '../../../api/health/doctors'; + +interface Props { + value?: string; + onChange?: (value: string, label: string) => void; + placeholder?: string; +} + +export function DoctorSelect({ value, onChange, placeholder }: Props) { + const [options, setOptions] = useState< + { value: string; label: string }[] + >([]); + const [fetching, setFetching] = useState(false); + + const handleSearch = useCallback(async (search: string) => { + if (!search || search.length < 1) { + setOptions([]); + return; + } + setFetching(true); + try { + const result = await doctorApi.list({ + search, + page_size: 20, + }); + setOptions( + result.data.map((d) => ({ + value: d.id, + label: `${d.name}${d.department ? ` - ${d.department}` : ''}`, + })), + ); + } finally { + setFetching(false); + } + }, []); + + return ( + { + const opt = options.find((o) => o.value === val); + onChange?.(val, opt?.label ?? ''); + }} + loading={fetching} + options={options} + value={value} + placeholder={placeholder ?? '搜索患者'} + allowClear + /> + ); +} diff --git a/apps/web/src/pages/health/components/StatusTag.tsx b/apps/web/src/pages/health/components/StatusTag.tsx new file mode 100644 index 0000000..6cc5cd3 --- /dev/null +++ b/apps/web/src/pages/health/components/StatusTag.tsx @@ -0,0 +1,30 @@ +import { Tag } from 'antd'; + +const STATUS_CONFIG: Record = { + // 预约状态 + pending: { color: 'gold', label: '待确认' }, + confirmed: { color: 'blue', label: '已确认' }, + completed: { color: 'green', label: '已完成' }, + cancelled: { color: 'default', label: '已取消' }, + no_show: { color: 'red', label: '未到诊' }, + // 随访状态 + overdue: { color: 'red', label: '逾期' }, + in_progress: { color: 'processing', label: '进行中' }, + // 咨询状态 + waiting: { color: 'gold', label: '等待中' }, + active: { color: 'green', label: '进行中' }, + closed: { color: 'default', label: '已关闭' }, + // 患者状态 + inactive: { color: 'default', label: '停用' }, + deceased: { color: 'default', label: '已故' }, + verified: { color: 'green', label: '已认证' }, +}; + +interface Props { + status: string; +} + +export function StatusTag({ status }: Props) { + const cfg = STATUS_CONFIG[status] || { color: 'default' as const, label: status }; + return {cfg.label}; +} diff --git a/apps/web/src/pages/health/components/VitalSignsChart.tsx b/apps/web/src/pages/health/components/VitalSignsChart.tsx new file mode 100644 index 0000000..2094f63 --- /dev/null +++ b/apps/web/src/pages/health/components/VitalSignsChart.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { Line } from '@ant-design/charts'; +import { Spin, Empty, Select, Space } from 'antd'; +import { healthDataApi } from '../../../api/health/healthData'; + +interface Props { + patientId: string; + indicator?: string; +} + +const INDICATORS = [ + { value: 'systolic_bp_morning', label: '收缩压(晨)' }, + { value: 'diastolic_bp_morning', label: '舒张压(晨)' }, + { value: 'heart_rate', label: '心率' }, + { value: 'weight', label: '体重' }, + { value: 'blood_sugar', label: '血糖' }, +]; + +export function VitalSignsChart({ patientId, indicator: initialIndicator }: Props) { + const [indicator, setIndicator] = useState(initialIndicator ?? 'systolic_bp_morning'); + const [data, setData] = useState<{ date: string; value: number }[]>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!patientId || !indicator) return; + setLoading(true); + healthDataApi + .getIndicatorTimeseries(patientId, indicator) + .then(setData) + .finally(() => setLoading(false)); + }, [patientId, indicator]); + + if (loading) return ; + if (data.length === 0) return ; + + const config = { + data, + xField: 'date', + yField: 'value', + smooth: true, + point: { shapeField: 'circle', sizeField: 4 }, + }; + + return ( + +