feat(health): Phase 4 跨模块集成与架构优化 — 通知/标签/待办/数据录入
后端: - erp-message: 添加 appointment.created/confirmed/cancelled 事件监听,自动发送站内通知 - erp-health: 新增 GET /health/patient-tags 标签列表端点 + list_tags service - wechat-templates: 添加 isTemplateConfigured 运行时校验 前端: - 新增 Zustand useHealthStore 共享患者/医生名称缓存 - PatientTagManage: UUID 输入替换为 Checkbox 标签选择器 - VitalSignsTab: 添加体征数据录入 Modal (血压/心率/体重/血糖) - LabReportsTab: 添加化验报告创建 Modal - HealthRecordsTab: 添加健康记录创建 Modal - patients API: 添加 TagItem 类型 + listTags 方法 小程序: - 首页待办事项接入预约和随访 API,替换硬编码 EmptyState
This commit is contained in:
@@ -127,6 +127,43 @@
|
|||||||
margin: 0 24px;
|
margin: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upcoming-list {
|
||||||
|
background: $card;
|
||||||
|
border-radius: $r;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-bottom: 1px solid $bd;
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-item-main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-item-title {
|
||||||
|
font-size: 28px;
|
||||||
|
color: $tx;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-item-sub {
|
||||||
|
font-size: 22px;
|
||||||
|
color: $tx3;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-item-arrow {
|
||||||
|
font-size: 36px;
|
||||||
|
color: $tx3;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-hint {
|
.empty-hint {
|
||||||
background: $card;
|
background: $card;
|
||||||
border-radius: $r;
|
border-radius: $r;
|
||||||
|
|||||||
@@ -1,22 +1,75 @@
|
|||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import { useState } from 'react';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import { useAuthStore } from '../../stores/auth';
|
import { useAuthStore } from '../../stores/auth';
|
||||||
import { useHealthStore } from '../../stores/health';
|
import { useHealthStore } from '../../stores/health';
|
||||||
import EmptyState from '../../components/EmptyState';
|
import EmptyState from '../../components/EmptyState';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
import { trackPageView } from '@/services/analytics';
|
import { trackPageView } from '@/services/analytics';
|
||||||
|
import * as appointmentApi from '@/services/appointment';
|
||||||
|
import * as followupApi from '@/services/followup';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
|
interface UpcomingItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
type: 'appointment' | 'followup';
|
||||||
|
}
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { user, currentPatient, restore: restoreAuth } = useAuthStore();
|
const { user, currentPatient, restore: restoreAuth } = useAuthStore();
|
||||||
const { todaySummary, loading, refreshToday } = useHealthStore();
|
const { todaySummary, loading, refreshToday } = useHealthStore();
|
||||||
|
const [upcomingItems, setUpcomingItems] = useState<UpcomingItem[]>([]);
|
||||||
|
const [upcomingLoading, setUpcomingLoading] = useState(false);
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
restoreAuth();
|
restoreAuth();
|
||||||
refreshToday();
|
refreshToday();
|
||||||
|
loadUpcoming();
|
||||||
trackPageView('home');
|
trackPageView('home');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loadUpcoming = async () => {
|
||||||
|
const patientId = useAuthStore.getState().currentPatient?.id;
|
||||||
|
if (!patientId) return;
|
||||||
|
setUpcomingLoading(true);
|
||||||
|
try {
|
||||||
|
const items: UpcomingItem[] = [];
|
||||||
|
const [apptRes, taskRes] = await Promise.allSettled([
|
||||||
|
appointmentApi.listAppointments(patientId, 1),
|
||||||
|
followupApi.listTasks(patientId, 'pending'),
|
||||||
|
]);
|
||||||
|
if (apptRes.status === 'fulfilled') {
|
||||||
|
for (const a of apptRes.value.data.slice(0, 3)) {
|
||||||
|
if (a.status === 'pending' || a.status === 'confirmed') {
|
||||||
|
items.push({
|
||||||
|
id: a.id,
|
||||||
|
title: `预约: ${a.appointment_date} ${a.start_time}`,
|
||||||
|
subtitle: `${a.doctor_name || '医护'} · ${a.status === 'pending' ? '待确认' : '已确认'}`,
|
||||||
|
type: 'appointment',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (taskRes.status === 'fulfilled') {
|
||||||
|
for (const t of taskRes.value.data.slice(0, 2)) {
|
||||||
|
items.push({
|
||||||
|
id: t.id,
|
||||||
|
title: `随访: ${t.task_type}`,
|
||||||
|
subtitle: `${t.description?.slice(0, 30) || ''} · 截止 ${t.due_date}`,
|
||||||
|
type: 'followup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUpcomingItems(items);
|
||||||
|
} catch {
|
||||||
|
setUpcomingItems([]);
|
||||||
|
} finally {
|
||||||
|
setUpcomingLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
|
const greeting = hour < 12 ? '早上好' : hour < 18 ? '下午好' : '晚上好';
|
||||||
const displayName = user?.display_name || currentPatient?.name || '访客';
|
const displayName = user?.display_name || currentPatient?.name || '访客';
|
||||||
@@ -97,7 +150,33 @@ export default function Index() {
|
|||||||
|
|
||||||
<View className='upcoming'>
|
<View className='upcoming'>
|
||||||
<Text className='section-title'>待办事项</Text>
|
<Text className='section-title'>待办事项</Text>
|
||||||
<EmptyState icon='📋' text='暂无待办事项' hint='预约挂号后将在此显示' />
|
{upcomingLoading ? (
|
||||||
|
<Loading />
|
||||||
|
) : upcomingItems.length === 0 ? (
|
||||||
|
<EmptyState icon='📋' text='暂无待办事项' hint='预约挂号后将在此显示' />
|
||||||
|
) : (
|
||||||
|
<View className='upcoming-list'>
|
||||||
|
{upcomingItems.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.id}
|
||||||
|
className='upcoming-item'
|
||||||
|
onClick={() => {
|
||||||
|
if (item.type === 'appointment') {
|
||||||
|
Taro.navigateTo({ url: `/pages/appointment/index` });
|
||||||
|
} else {
|
||||||
|
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='upcoming-item-main'>
|
||||||
|
<Text className='upcoming-item-title'>{item.title}</Text>
|
||||||
|
<Text className='upcoming-item-sub'>{item.subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='upcoming-item-arrow'>›</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
|
// 微信订阅消息模板 ID — 需在微信公众平台注册后填入
|
||||||
|
// 注册路径:公众平台 → 功能 → 订阅消息 → 添加模板
|
||||||
|
// TODO: 上线前必须配置
|
||||||
export const TEMPLATE_IDS = {
|
export const TEMPLATE_IDS = {
|
||||||
APPOINTMENT_REMINDER: '',
|
APPOINTMENT_REMINDER: '',
|
||||||
FOLLOWUP_REMINDER: '',
|
FOLLOWUP_REMINDER: '',
|
||||||
REPORT_NOTIFICATION: '',
|
REPORT_NOTIFICATION: '',
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
/** 检查模板 ID 是否已配置,未配置时返回 false 并打印警告 */
|
||||||
|
export function isTemplateConfigured(key: keyof typeof TEMPLATE_IDS): boolean {
|
||||||
|
if (!TEMPLATE_IDS[key]) {
|
||||||
|
console.warn(`[wechat-templates] 模板 ${key} 未配置,请在微信公众平台注册并填入 ID`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export interface CreateFamilyMemberReq {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
export const patientApi = {
|
export const patientApi = {
|
||||||
list: async (params: {
|
list: async (params: {
|
||||||
@@ -180,4 +187,12 @@ export const patientApi = {
|
|||||||
removeDoctor: async (id: string, doctorId: string) => {
|
removeDoctor: async (id: string, doctorId: string) => {
|
||||||
await client.delete(`/health/patients/${id}/doctors/${doctorId}`);
|
await client.delete(`/health/patients/${id}/doctors/${doctorId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listTags: async () => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: TagItem[];
|
||||||
|
}>('/health/patient-tags');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Space,
|
Space,
|
||||||
Modal,
|
Modal,
|
||||||
Input,
|
|
||||||
Tag,
|
Tag,
|
||||||
Card,
|
Card,
|
||||||
|
Checkbox,
|
||||||
message,
|
message,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
import { TagsOutlined, AppstoreOutlined } from '@ant-design/icons';
|
||||||
import { patientApi } from '../../api/health/patients';
|
import { patientApi, type TagItem } from '../../api/health/patients';
|
||||||
import type { PatientListItem } from '../../api/health/patients';
|
import type { PatientListItem } from '../../api/health/patients';
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
|
|
||||||
@@ -22,7 +22,8 @@ export default function PatientTagManage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tagModalOpen, setTagModalOpen] = useState(false);
|
const [tagModalOpen, setTagModalOpen] = useState(false);
|
||||||
const [selectedPatient, setSelectedPatient] = useState<PatientListItem | null>(null);
|
const [selectedPatient, setSelectedPatient] = useState<PatientListItem | null>(null);
|
||||||
const [tagInput, setTagInput] = useState('');
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [allTags, setAllTags] = useState<TagItem[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
@@ -42,28 +43,34 @@ export default function PatientTagManage() {
|
|||||||
[page],
|
[page],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tags = await patientApi.listTags();
|
||||||
|
setAllTags(tags);
|
||||||
|
} catch {
|
||||||
|
// 标签列表加载失败不阻塞页面
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPatients();
|
fetchPatients();
|
||||||
}, [fetchPatients]);
|
fetchTags();
|
||||||
|
}, [fetchPatients, fetchTags]);
|
||||||
|
|
||||||
const openTagModal = (record: PatientListItem) => {
|
const openTagModal = (record: PatientListItem) => {
|
||||||
setSelectedPatient(record);
|
setSelectedPatient(record);
|
||||||
setTagInput('');
|
const existingTags = (record as PatientListItem & { tag_ids?: string[] }).tag_ids || [];
|
||||||
|
setSelectedTagIds(existingTags);
|
||||||
setTagModalOpen(true);
|
setTagModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveTags = async () => {
|
const handleSaveTags = async () => {
|
||||||
if (!selectedPatient) return;
|
if (!selectedPatient) return;
|
||||||
const tagIds = tagInput
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await patientApi.manageTags(selectedPatient.id, tagIds);
|
await patientApi.manageTags(selectedPatient.id, selectedTagIds);
|
||||||
message.success('标签更新成功');
|
message.success('标签更新成功');
|
||||||
setTagModalOpen(false);
|
setTagModalOpen(false);
|
||||||
setTagInput('');
|
|
||||||
fetchPatients();
|
fetchPatients();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('标签更新失败');
|
message.error('标签更新失败');
|
||||||
@@ -72,6 +79,12 @@ export default function PatientTagManage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tagId: string) => {
|
||||||
|
setSelectedTagIds((prev) =>
|
||||||
|
prev.includes(tagId) ? prev.filter((id) => id !== tagId) : [...prev, tagId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '患者姓名',
|
title: '患者姓名',
|
||||||
@@ -121,18 +134,22 @@ export default function PatientTagManage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
{tagIds.map((t) => (
|
{tagIds.map((t) => {
|
||||||
<Tag
|
const tagDef = allTags.find((at) => at.id === t);
|
||||||
key={t}
|
return (
|
||||||
style={{
|
<Tag
|
||||||
background: isDark ? '#0f172a' : '#f0f9ff',
|
key={t}
|
||||||
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
color={tagDef?.color || undefined}
|
||||||
color: isDark ? '#7dd3fc' : '#0369a1',
|
style={tagDef?.color ? undefined : {
|
||||||
}}
|
background: isDark ? '#0f172a' : '#f0f9ff',
|
||||||
>
|
border: `1px solid ${isDark ? '#1e3a5f' : '#bae6fd'}`,
|
||||||
{t}
|
color: isDark ? '#7dd3fc' : '#0369a1',
|
||||||
</Tag>
|
}}
|
||||||
))}
|
>
|
||||||
|
{tagDef?.name || t.slice(0, 8)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -175,7 +192,6 @@ export default function PatientTagManage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 说明卡片 */}
|
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
@@ -185,25 +201,18 @@ export default function PatientTagManage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
<AppstoreOutlined
|
<AppstoreOutlined style={{ fontSize: 20, color: '#0ea5e9', marginTop: 2 }} />
|
||||||
style={{ fontSize: 20, color: '#0ea5e9', marginTop: 2 }}
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||||
标签管理说明
|
标签管理说明
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Typography.Paragraph
|
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0', fontSize: 13 }}>
|
||||||
type="secondary"
|
为患者分配分类标签,便于快速筛选和管理。勾选需要关联的标签后保存即可。
|
||||||
style={{ margin: '4px 0 0', fontSize: 13 }}
|
|
||||||
>
|
|
||||||
标签通过患者管理页面进行关联。您可以在下方列表中为每位患者管理标签,输入标签
|
|
||||||
ID(逗号分隔)进行批量设置。
|
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 页面标题 */}
|
|
||||||
<div className="erp-page-header">
|
<div className="erp-page-header">
|
||||||
<div>
|
<div>
|
||||||
<h4>标签管理</h4>
|
<h4>标签管理</h4>
|
||||||
@@ -211,7 +220,6 @@ export default function PatientTagManage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 表格容器 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: isDark ? '#111827' : '#FFFFFF',
|
background: isDark ? '#111827' : '#FFFFFF',
|
||||||
@@ -239,29 +247,31 @@ export default function PatientTagManage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标签管理弹窗 */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={`管理标签 - ${selectedPatient?.name || ''}`}
|
title={`管理标签 - ${selectedPatient?.name || ''}`}
|
||||||
open={tagModalOpen}
|
open={tagModalOpen}
|
||||||
onCancel={() => {
|
onCancel={() => setTagModalOpen(false)}
|
||||||
setTagModalOpen(false);
|
|
||||||
setTagInput('');
|
|
||||||
}}
|
|
||||||
onOk={handleSaveTags}
|
onOk={handleSaveTags}
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
okText="保存"
|
okText="保存"
|
||||||
width={440}
|
width={440}
|
||||||
>
|
>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
{allTags.length === 0 ? (
|
||||||
请输入标签 ID,多个标签用英文逗号分隔。
|
<Typography.Text type="secondary">暂无可用标签</Typography.Text>
|
||||||
</Typography.Paragraph>
|
) : (
|
||||||
<Input
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
placeholder="例如: tag-001, tag-002, tag-003"
|
{allTags.map((tag) => (
|
||||||
value={tagInput}
|
<Checkbox
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
key={tag.id}
|
||||||
onPressEnter={handleSaveTags}
|
checked={selectedTagIds.includes(tag.id)}
|
||||||
/>
|
onChange={() => toggleTag(tag.id)}
|
||||||
|
>
|
||||||
|
<Tag color={tag.color || undefined}>{tag.name}</Tag>
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Table, Tag } from 'antd';
|
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
import type { HealthRecord } from '../../../api/health/healthData';
|
import type { HealthRecord } from '../../../api/health/healthData';
|
||||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||||
@@ -9,69 +10,91 @@ interface Props {
|
|||||||
patientId: string;
|
patientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<HealthRecord> = [
|
const columns = [
|
||||||
{
|
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
title: '记录类型',
|
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||||
dataIndex: 'record_type',
|
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||||
key: 'record_type',
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||||
width: 120,
|
|
||||||
render: (v: string) => <Tag>{v}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '记录日期',
|
|
||||||
dataIndex: 'record_date',
|
|
||||||
key: 'record_date',
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '内容',
|
|
||||||
dataIndex: 'content',
|
|
||||||
key: 'content',
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 170,
|
|
||||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 健康档案标签页 — 分页表格
|
|
||||||
*/
|
|
||||||
export function HealthRecordsTab({ patientId }: Props) {
|
export function HealthRecordsTab({ patientId }: Props) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
async (page: number, pageSize: number) => {
|
async (page: number, pageSize: number) => {
|
||||||
return healthDataApi.listHealthRecords(patientId, {
|
return healthDataApi.listHealthRecords(patientId, { page, page_size: pageSize });
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[patientId],
|
[patientId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(
|
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
|
||||||
fetcher,
|
|
||||||
10,
|
const handleCreate = async (values: {
|
||||||
);
|
record_type: string;
|
||||||
|
record_date: Dayjs;
|
||||||
|
content?: string;
|
||||||
|
}) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await healthDataApi.createHealthRecord(patientId, {
|
||||||
|
record_type: values.record_type,
|
||||||
|
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||||
|
content: values.content,
|
||||||
|
});
|
||||||
|
message.success('健康记录添加成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
message.error('添加失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<div>
|
||||||
columns={columns}
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
dataSource={data}
|
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||||
rowKey="id"
|
添加记录
|
||||||
loading={loading}
|
</Button>
|
||||||
size="small"
|
</div>
|
||||||
pagination={{
|
<Table
|
||||||
current: page,
|
columns={columns}
|
||||||
total,
|
dataSource={data}
|
||||||
pageSize: 10,
|
rowKey="id"
|
||||||
onChange: (p) => refresh(p),
|
loading={loading}
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
size="small"
|
||||||
style: { margin: 0 },
|
pagination={{
|
||||||
}}
|
current: page, total, pageSize: 10,
|
||||||
/>
|
onChange: (p) => refresh(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加健康记录"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
destroyOnClose
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||||
|
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请输入类型' }]}>
|
||||||
|
<Input placeholder="如:门诊记录、出院小结、体检报告" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="内容">
|
||||||
|
<Input.TextArea rows={4} placeholder="健康记录详细内容" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Table, Tag } from 'antd';
|
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
import type { LabReport } from '../../../api/health/healthData';
|
import type { LabReport } from '../../../api/health/healthData';
|
||||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||||
@@ -9,69 +10,91 @@ interface Props {
|
|||||||
patientId: string;
|
patientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<LabReport> = [
|
const columns = [
|
||||||
{
|
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
|
||||||
title: '报告日期',
|
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||||
dataIndex: 'report_date',
|
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
|
||||||
key: 'report_date',
|
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '报告类型',
|
|
||||||
dataIndex: 'report_type',
|
|
||||||
key: 'report_type',
|
|
||||||
width: 120,
|
|
||||||
render: (v: string) => <Tag>{v}</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '医生解读',
|
|
||||||
dataIndex: 'doctor_interpretation',
|
|
||||||
key: 'doctor_interpretation',
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'created_at',
|
|
||||||
key: 'created_at',
|
|
||||||
width: 170,
|
|
||||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 化验报告标签页 — 分页表格
|
|
||||||
*/
|
|
||||||
export function LabReportsTab({ patientId }: Props) {
|
export function LabReportsTab({ patientId }: Props) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
async (page: number, pageSize: number) => {
|
async (page: number, pageSize: number) => {
|
||||||
return healthDataApi.listLabReports(patientId, {
|
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[patientId],
|
[patientId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(
|
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
|
||||||
fetcher,
|
|
||||||
10,
|
const handleCreate = async (values: {
|
||||||
);
|
report_date: Dayjs;
|
||||||
|
report_type: string;
|
||||||
|
doctor_interpretation?: string;
|
||||||
|
}) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await healthDataApi.createLabReport(patientId, {
|
||||||
|
report_date: values.report_date.format('YYYY-MM-DD'),
|
||||||
|
report_type: values.report_type,
|
||||||
|
doctor_interpretation: values.doctor_interpretation,
|
||||||
|
});
|
||||||
|
message.success('化验报告添加成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
message.error('添加失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<div>
|
||||||
columns={columns}
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
dataSource={data}
|
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||||
rowKey="id"
|
添加报告
|
||||||
loading={loading}
|
</Button>
|
||||||
size="small"
|
</div>
|
||||||
pagination={{
|
<Table
|
||||||
current: page,
|
columns={columns}
|
||||||
total,
|
dataSource={data}
|
||||||
pageSize: 10,
|
rowKey="id"
|
||||||
onChange: (p) => refresh(p),
|
loading={loading}
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
size="small"
|
||||||
style: { margin: 0 },
|
pagination={{
|
||||||
}}
|
current: page, total, pageSize: 10,
|
||||||
/>
|
onChange: (p) => refresh(p),
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
style: { margin: 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加化验报告"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
destroyOnClose
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||||
|
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="report_type" label="报告类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||||
|
<Input placeholder="如:血常规、生化全套" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="doctor_interpretation" label="医生解读">
|
||||||
|
<Input.TextArea rows={3} placeholder="检查结果解读备注" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Table } from 'antd';
|
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
import type { VitalSigns } from '../../../api/health/healthData';
|
import type { VitalSigns } from '../../../api/health/healthData';
|
||||||
import { VitalSignsChart } from './VitalSignsChart';
|
import { VitalSignsChart } from './VitalSignsChart';
|
||||||
@@ -10,71 +11,71 @@ interface Props {
|
|||||||
patientId: string;
|
patientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<VitalSigns> = [
|
const columns = [
|
||||||
{
|
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||||
title: '记录日期',
|
{ title: '收缩压(晨)', dataIndex: 'systolic_bp_morning', key: 'systolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') },
|
||||||
dataIndex: 'record_date',
|
{ title: '舒张压(晨)', dataIndex: 'diastolic_bp_morning', key: 'diastolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') },
|
||||||
key: 'record_date',
|
{ title: '心率', dataIndex: 'heart_rate', key: 'heart_rate', width: 80, render: (v?: number) => (v != null ? `${v} bpm` : '-') },
|
||||||
width: 120,
|
{ title: '体重', dataIndex: 'weight', key: 'weight', width: 80, render: (v?: number) => (v != null ? `${v} kg` : '-') },
|
||||||
},
|
{ title: '血糖', dataIndex: 'blood_sugar', key: 'blood_sugar', width: 80, render: (v?: number) => (v != null ? `${v} mmol/L` : '-') },
|
||||||
{
|
|
||||||
title: '收缩压(晨)',
|
|
||||||
dataIndex: 'systolic_bp_morning',
|
|
||||||
key: 'systolic_bp_morning',
|
|
||||||
width: 110,
|
|
||||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '舒张压(晨)',
|
|
||||||
dataIndex: 'diastolic_bp_morning',
|
|
||||||
key: 'diastolic_bp_morning',
|
|
||||||
width: 110,
|
|
||||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '心率',
|
|
||||||
dataIndex: 'heart_rate',
|
|
||||||
key: 'heart_rate',
|
|
||||||
width: 80,
|
|
||||||
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '体重',
|
|
||||||
dataIndex: 'weight',
|
|
||||||
key: 'weight',
|
|
||||||
width: 80,
|
|
||||||
render: (v?: number) => (v != null ? `${v} kg` : '-'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '血糖',
|
|
||||||
dataIndex: 'blood_sugar',
|
|
||||||
key: 'blood_sugar',
|
|
||||||
width: 80,
|
|
||||||
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* 体征数据标签页 — 含趋势图 + 分页表格
|
|
||||||
*/
|
|
||||||
export function VitalSignsTab({ patientId }: Props) {
|
export function VitalSignsTab({ patientId }: Props) {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetcher = useCallback(
|
const fetcher = useCallback(
|
||||||
async (page: number, pageSize: number) => {
|
async (page: number, pageSize: number) => {
|
||||||
return healthDataApi.listVitalSigns(patientId, {
|
return healthDataApi.listVitalSigns(patientId, { page, page_size: pageSize });
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[patientId],
|
[patientId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(
|
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
|
||||||
fetcher,
|
|
||||||
10,
|
const handleCreate = async (values: {
|
||||||
);
|
record_date: Dayjs;
|
||||||
|
systolic_bp_morning?: number;
|
||||||
|
diastolic_bp_morning?: number;
|
||||||
|
heart_rate?: number;
|
||||||
|
weight?: number;
|
||||||
|
blood_sugar?: number;
|
||||||
|
water_intake_ml?: number;
|
||||||
|
urine_output_ml?: number;
|
||||||
|
notes?: string;
|
||||||
|
}) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await healthDataApi.createVitalSigns(patientId, {
|
||||||
|
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||||
|
systolic_bp_morning: values.systolic_bp_morning,
|
||||||
|
diastolic_bp_morning: values.diastolic_bp_morning,
|
||||||
|
heart_rate: values.heart_rate,
|
||||||
|
weight: values.weight,
|
||||||
|
blood_sugar: values.blood_sugar,
|
||||||
|
water_intake_ml: values.water_intake_ml,
|
||||||
|
urine_output_ml: values.urine_output_ml,
|
||||||
|
notes: values.notes,
|
||||||
|
});
|
||||||
|
message.success('体征数据录入成功');
|
||||||
|
setModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
message.error('录入失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||||
|
录入体征
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<VitalSignsChart patientId={patientId} />
|
<VitalSignsChart patientId={patientId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -85,14 +86,50 @@ export function VitalSignsTab({ patientId }: Props) {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page, total, pageSize: 10,
|
||||||
total,
|
|
||||||
pageSize: 10,
|
|
||||||
onChange: (p) => refresh(p),
|
onChange: (p) => refresh(p),
|
||||||
showTotal: (t) => `共 ${t} 条`,
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
style: { margin: 0 },
|
style: { margin: 0 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Modal
|
||||||
|
title="录入体征数据"
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
destroyOnClose
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||||
|
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<Form.Item name="systolic_bp_morning" label="收缩压(晨) mmHg">
|
||||||
|
<InputNumber min={50} max={300} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="diastolic_bp_morning" label="舒张压(晨) mmHg">
|
||||||
|
<InputNumber min={30} max={200} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="heart_rate" label="心率 bpm">
|
||||||
|
<InputNumber min={30} max={250} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="weight" label="体重 kg">
|
||||||
|
<InputNumber min={1} max={500} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="blood_sugar" label="血糖 mmol/L">
|
||||||
|
<InputNumber min={0} max={50} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="water_intake_ml" label="饮水量 ml">
|
||||||
|
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
apps/web/src/stores/health.ts
Normal file
75
apps/web/src/stores/health.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { patientApi } from '../api/health/patients';
|
||||||
|
import { doctorApi } from '../api/health/doctors';
|
||||||
|
|
||||||
|
interface HealthState {
|
||||||
|
patientNames: Record<string, string>;
|
||||||
|
doctorNames: Record<string, string>;
|
||||||
|
loadingIds: Set<string>;
|
||||||
|
|
||||||
|
resolvePatientName: (id: string) => Promise<string>;
|
||||||
|
resolveDoctorName: (id: string) => Promise<string>;
|
||||||
|
getPatientName: (id: string) => string;
|
||||||
|
getDoctorName: (id: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHealthStore = create<HealthState>((set, get) => ({
|
||||||
|
patientNames: {},
|
||||||
|
doctorNames: {},
|
||||||
|
loadingIds: new Set(),
|
||||||
|
|
||||||
|
resolvePatientName: async (id: string) => {
|
||||||
|
const { patientNames, loadingIds } = get();
|
||||||
|
if (patientNames[id]) return patientNames[id];
|
||||||
|
if (loadingIds.has(`p:${id}`)) return id.slice(0, 8);
|
||||||
|
|
||||||
|
const newLoading = new Set(loadingIds);
|
||||||
|
newLoading.add(`p:${id}`);
|
||||||
|
set({ loadingIds: newLoading });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await patientApi.get(id);
|
||||||
|
const name = detail.name;
|
||||||
|
set((s) => ({
|
||||||
|
patientNames: { ...s.patientNames, [id]: name },
|
||||||
|
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
|
||||||
|
}));
|
||||||
|
return name;
|
||||||
|
} catch {
|
||||||
|
set((s) => ({
|
||||||
|
patientNames: { ...s.patientNames, [id]: id.slice(0, 8) },
|
||||||
|
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `p:${id}`)),
|
||||||
|
}));
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveDoctorName: async (id: string) => {
|
||||||
|
const { doctorNames, loadingIds } = get();
|
||||||
|
if (doctorNames[id]) return doctorNames[id];
|
||||||
|
if (loadingIds.has(`d:${id}`)) return id.slice(0, 8);
|
||||||
|
|
||||||
|
const newLoading = new Set(loadingIds);
|
||||||
|
newLoading.add(`d:${id}`);
|
||||||
|
set({ loadingIds: newLoading });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await doctorApi.get(id);
|
||||||
|
const name = detail.name;
|
||||||
|
set((s) => ({
|
||||||
|
doctorNames: { ...s.doctorNames, [id]: name },
|
||||||
|
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
|
||||||
|
}));
|
||||||
|
return name;
|
||||||
|
} catch {
|
||||||
|
set((s) => ({
|
||||||
|
doctorNames: { ...s.doctorNames, [id]: id.slice(0, 8) },
|
||||||
|
loadingIds: new Set([...s.loadingIds].filter((k) => k !== `d:${id}`)),
|
||||||
|
}));
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPatientName: (id: string) => get().patientNames[id] || id.slice(0, 8),
|
||||||
|
getDoctorName: (id: string) => get().doctorNames[id] || id.slice(0, 8),
|
||||||
|
}));
|
||||||
@@ -130,3 +130,11 @@ pub struct PatientListQuery {
|
|||||||
pub tag_id: Option<Uuid>,
|
pub tag_id: Option<Uuid>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||||
|
pub struct TagResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -306,3 +306,16 @@ pub struct FamilyMemberUpdateWithVersion {
|
|||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_tags<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<crate::dto::patient_dto::TagResp>>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let tags = patient_service::list_tags(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(tags)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ impl HealthModule {
|
|||||||
"/health/patients/{id}/tags",
|
"/health/patients/{id}/tags",
|
||||||
axum::routing::post(patient_handler::manage_patient_tags),
|
axum::routing::post(patient_handler::manage_patient_tags),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/health/patient-tags",
|
||||||
|
axum::routing::get(patient_handler::list_tags),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/health/patients/{id}/health-summary",
|
"/health/patients/{id}/health-summary",
|
||||||
axum::routing::get(patient_handler::get_health_summary),
|
axum::routing::get(patient_handler::get_health_summary),
|
||||||
|
|||||||
@@ -760,3 +760,27 @@ fn model_to_resp_decrypted(crypto: &crate::crypto::HealthCrypto, m: patient::Mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn list_tags(
|
||||||
|
state: &crate::state::HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> HealthResult<Vec<crate::dto::patient_dto::TagResp>> {
|
||||||
|
use crate::entity::patient_tag;
|
||||||
|
let tags = patient_tag::Entity::find()
|
||||||
|
.filter(patient_tag::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(patient_tag::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(patient_tag::Column::Name)
|
||||||
|
.all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| crate::error::HealthError::DbError(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(tags
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| crate::dto::patient_dto::TagResp {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
color: t.color,
|
||||||
|
description: t.description,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,6 +195,91 @@ async fn handle_workflow_event(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 预约事件通知
|
||||||
|
"appointment.created" => {
|
||||||
|
let appointment_id = event
|
||||||
|
.payload
|
||||||
|
.get("appointment_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let patient_id = event
|
||||||
|
.payload
|
||||||
|
.get("patient_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
|
if let Some(pid) = patient_id {
|
||||||
|
let _ = crate::service::message_service::MessageService::send_system(
|
||||||
|
event.tenant_id,
|
||||||
|
pid,
|
||||||
|
"预约已创建".to_string(),
|
||||||
|
format!("您的新预约 {} 已创建,请等待确认。", &appointment_id[..8.min(appointment_id.len())]),
|
||||||
|
"normal",
|
||||||
|
Some("appointment".to_string()),
|
||||||
|
uuid::Uuid::parse_str(appointment_id).ok(),
|
||||||
|
db,
|
||||||
|
event_bus,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"appointment.confirmed" => {
|
||||||
|
let appointment_id = event
|
||||||
|
.payload
|
||||||
|
.get("appointment_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let patient_id = event
|
||||||
|
.payload
|
||||||
|
.get("patient_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
|
if let Some(pid) = patient_id {
|
||||||
|
let _ = crate::service::message_service::MessageService::send_system(
|
||||||
|
event.tenant_id,
|
||||||
|
pid,
|
||||||
|
"预约已确认".to_string(),
|
||||||
|
format!("您的预约 {} 已确认,请按时就诊。", &appointment_id[..8.min(appointment_id.len())]),
|
||||||
|
"important",
|
||||||
|
Some("appointment".to_string()),
|
||||||
|
uuid::Uuid::parse_str(appointment_id).ok(),
|
||||||
|
db,
|
||||||
|
event_bus,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"appointment.cancelled" => {
|
||||||
|
let appointment_id = event
|
||||||
|
.payload
|
||||||
|
.get("appointment_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let patient_id = event
|
||||||
|
.payload
|
||||||
|
.get("patient_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| uuid::Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
|
if let Some(pid) = patient_id {
|
||||||
|
let _ = crate::service::message_service::MessageService::send_system(
|
||||||
|
event.tenant_id,
|
||||||
|
pid,
|
||||||
|
"预约已取消".to_string(),
|
||||||
|
format!("您的预约 {} 已被取消。", &appointment_id[..8.min(appointment_id.len())]),
|
||||||
|
"normal",
|
||||||
|
Some("appointment".to_string()),
|
||||||
|
uuid::Uuid::parse_str(appointment_id).ok(),
|
||||||
|
db,
|
||||||
|
event_bus,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in New Issue
Block a user