feat(web): 健康模块通用组件 8 个

- StatusTag: 通用状态标签(预约/随访/咨询/患者状态)
- PatientSelect: 患者远程搜索选择器
- DoctorSelect: 医护远程搜索选择器
- VitalSignsChart: ECharts 趋势图(可切换指标)
- CalendarView: 日历视图(排班展示)
- ChatBubble: 聊天气泡(角色区分左右布局)
- ImagePreview: 图片预览(Ant Design Image.PreviewGroup)
- ExportButton: 导出按钮(blob 下载)
This commit is contained in:
iven
2026-04-25 00:40:11 +08:00
parent 778ae79d84
commit 6296ce22d2
8 changed files with 381 additions and 0 deletions

View File

@@ -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<string, ScheduleItem[]>;
}
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 (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{items.slice(0, 3).map((s) => (
<li key={s.id}>
<Badge
status={s.current_appointments >= s.max_appointments ? 'error' : 'processing'}
text={`${s.start_time}-${s.end_time}`}
/>
</li>
))}
{items.length > 3 && (
<li style={{ color: '#999', fontSize: 12 }}>+{items.length - 3} </li>
)}
</ul>
);
};
return <Calendar cellRender={cellRender} />;
}

View File

@@ -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 (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{content}
</Typography.Text>
</div>
);
}
return (
<div style={{ display: 'flex', justifyContent: cfg.align, marginBottom: 12 }}>
{senderRole === 'patient' && (
<Avatar icon={<UserOutlined />} style={{ marginRight: 8, flexShrink: 0 }} />
)}
<div style={{ maxWidth: '70%' }}>
{senderName && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 2 }}
>
{senderName}
</Typography.Text>
)}
<div
style={{
background: cfg.bg,
color: cfg.color,
padding: '8px 12px',
borderRadius: 8,
wordBreak: 'break-word',
}}
>
<Typography.Paragraph
style={{ margin: 0, color: 'inherit' }}
>
{content}
</Typography.Paragraph>
</div>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{createdAt}
</Typography.Text>
</div>
{senderRole === 'doctor' && (
<Avatar icon={<UserOutlined />} style={{ marginLeft: 8, flexShrink: 0 }} />
)}
</div>
);
}

View File

@@ -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 (
<Select
showSearch
filterOption={false}
onSearch={handleSearch}
onChange={(val) => {
const opt = options.find((o) => o.value === val);
onChange?.(val, opt?.label ?? '');
}}
loading={fetching}
options={options}
value={value}
placeholder={placeholder ?? '搜索医护'}
allowClear
/>
);
}

View File

@@ -0,0 +1,45 @@
import { Button, message } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
interface Props {
fetchUrl: string;
params?: Record<string, string>;
filename?: string;
label?: string;
}
export function ExportButton({
fetchUrl,
params,
filename,
label = '导出',
}: Props) {
const handleExport = async () => {
try {
const query = params
? '?' + new URLSearchParams(params).toString()
: '';
const token = localStorage.getItem('access_token');
const resp = await fetch(`/api/v1${fetchUrl}${query}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) throw new Error('导出失败');
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename ?? `export_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
}
};
return (
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{label}
</Button>
);
}

View File

@@ -0,0 +1,25 @@
import { Image, Space } from 'antd';
interface Props {
urls: string[];
width?: number;
}
export function ImagePreview({ urls, width = 100 }: Props) {
if (!urls || urls.length === 0) return null;
return (
<Image.PreviewGroup>
<Space size={8} wrap>
{urls.map((url, idx) => (
<Image
key={idx}
src={url}
width={width}
style={{ borderRadius: 4, objectFit: 'cover' }}
/>
))}
</Space>
</Image.PreviewGroup>
);
}

View File

@@ -0,0 +1,55 @@
import { Select } from 'antd';
import { useState, useCallback } from 'react';
import { patientApi } from '../../../api/health/patients';
interface Props {
value?: string;
onChange?: (value: string, label: string) => void;
placeholder?: string;
}
export function PatientSelect({ 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 patientApi.list({
search,
page_size: 20,
});
setOptions(
result.data.map((p) => ({
value: p.id,
label: `${p.name}${p.gender ? ` (${p.gender})` : ''}`,
})),
);
} finally {
setFetching(false);
}
}, []);
return (
<Select
showSearch
filterOption={false}
onSearch={handleSearch}
onChange={(val) => {
const opt = options.find((o) => o.value === val);
onChange?.(val, opt?.label ?? '');
}}
loading={fetching}
options={options}
value={value}
placeholder={placeholder ?? '搜索患者'}
allowClear
/>
);
}

View File

@@ -0,0 +1,30 @@
import { Tag } from 'antd';
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
// 预约状态
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 <Tag color={cfg.color}>{cfg.label}</Tag>;
}

View File

@@ -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 <Spin />;
if (data.length === 0) return <Empty description="暂无数据" />;
const config = {
data,
xField: 'date',
yField: 'value',
smooth: true,
point: { shapeField: 'circle', sizeField: 4 },
};
return (
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={indicator}
onChange={setIndicator}
options={INDICATORS}
style={{ width: 180 }}
/>
<Line {...config} />
</Space>
);
}