feat(web): 健康模块通用组件 8 个
- StatusTag: 通用状态标签(预约/随访/咨询/患者状态) - PatientSelect: 患者远程搜索选择器 - DoctorSelect: 医护远程搜索选择器 - VitalSignsChart: ECharts 趋势图(可切换指标) - CalendarView: 日历视图(排班展示) - ChatBubble: 聊天气泡(角色区分左右布局) - ImagePreview: 图片预览(Ant Design Image.PreviewGroup) - ExportButton: 导出按钮(blob 下载)
This commit is contained in:
42
apps/web/src/pages/health/components/CalendarView.tsx
Normal file
42
apps/web/src/pages/health/components/CalendarView.tsx
Normal 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} />;
|
||||
}
|
||||
74
apps/web/src/pages/health/components/ChatBubble.tsx
Normal file
74
apps/web/src/pages/health/components/ChatBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/health/components/DoctorSelect.tsx
Normal file
55
apps/web/src/pages/health/components/DoctorSelect.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/pages/health/components/ExportButton.tsx
Normal file
45
apps/web/src/pages/health/components/ExportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/pages/health/components/ImagePreview.tsx
Normal file
25
apps/web/src/pages/health/components/ImagePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/health/components/PatientSelect.tsx
Normal file
55
apps/web/src/pages/health/components/PatientSelect.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/src/pages/health/components/StatusTag.tsx
Normal file
30
apps/web/src/pages/health/components/StatusTag.tsx
Normal 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>;
|
||||
}
|
||||
55
apps/web/src/pages/health/components/VitalSignsChart.tsx
Normal file
55
apps/web/src/pages/health/components/VitalSignsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user