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