feat(miniprogram): 实现小程序透析模块 — 患者端查看 + 医护端录入/审阅
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

审计后续 H1: 补齐小程序端透析功能,对接后端 12 个 API 路由。

新增内容:
- 患者端: 透析记录列表/详情 + 透析处方列表/详情(只读,4 页面)
- 医护端: 透析记录列表/详情/创建 + 处方列表/详情/创建(6 页面)
- Service 层: dialysis.ts(患者端只读)+ doctor/dialysis.ts(医护端 CRUD)
- 集成入口: 医生工作台快捷操作 + 患者"我的"菜单 + 路由注册
- 基础设施: api.delete 扩展支持 data 参数(后端 delete 需要 version)
This commit is contained in:
iven
2026-04-30 16:48:39 +08:00
parent 84fafb0bc5
commit 36a55e116e
27 changed files with 3076 additions and 343 deletions

View File

@@ -26,6 +26,8 @@ export default defineAppConfig({
'followup/index', 'followup/detail/index',
'report/index', 'report/detail/index',
'alerts/index', 'alerts/detail/index',
'dialysis/index', 'dialysis/detail/index', 'dialysis/create/index',
'prescription/index', 'prescription/detail/index', 'prescription/create/index',
],
},
{
@@ -37,6 +39,8 @@ export default defineAppConfig({
pages: [
'family/index', 'family-add/index', 'reports/index',
'followups/index', 'medication/index', 'settings/index',
'dialysis-records/index', 'dialysis-records/detail/index',
'dialysis-prescriptions/index', 'dialysis-prescriptions/detail/index',
],
},
{

View File

@@ -0,0 +1,97 @@
@import '../../../../styles/variables.scss';
.create-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 200px;
}
.section {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
&--textarea {
flex-direction: column;
align-items: flex-start;
}
}
.form-label {
font-size: 26px;
color: $tx2;
flex-shrink: 0;
min-width: 140px;
}
.form-input {
flex: 1;
text-align: right;
font-size: 26px;
color: $tx;
}
.form-value {
font-size: 26px;
color: $tx;
&.placeholder {
color: $tx3;
}
}
.form-textarea {
width: 100%;
margin-top: 12px;
font-size: 26px;
color: $tx;
min-height: 120px;
background: $bg;
border-radius: $r-sm;
padding: 16px;
}
.submit-btn {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
margin-top: 24px;
&:active {
background: $pri-d;
}
&--disabled {
opacity: 0.5;
}
}
.submit-btn__text {
font-size: 30px;
font-weight: bold;
color: #fff;
}

View File

@@ -0,0 +1,244 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
interface FormState {
patient_id: string;
dialysis_date: string;
start_time: string;
end_time: string;
dialysis_type: string;
dialysis_duration: string;
blood_flow_rate: string;
dry_weight: string;
pre_weight: string;
post_weight: string;
pre_bp_systolic: string;
pre_bp_diastolic: string;
post_bp_systolic: string;
post_bp_diastolic: string;
pre_heart_rate: string;
post_heart_rate: string;
ultrafiltration_volume: string;
complication_notes: string;
}
const initialForm: FormState = {
patient_id: '',
dialysis_date: '',
start_time: '',
end_time: '',
dialysis_type: 'HD',
dialysis_duration: '',
blood_flow_rate: '',
dry_weight: '',
pre_weight: '',
post_weight: '',
pre_bp_systolic: '',
pre_bp_diastolic: '',
post_bp_systolic: '',
post_bp_diastolic: '',
pre_heart_rate: '',
post_heart_rate: '',
ultrafiltration_volume: '',
complication_notes: '',
};
export default function DialysisCreate() {
const router = useRouter();
const id = router.params.id || '';
const version = router.params.version ? Number(router.params.version) : 0;
const patientIdFromRoute = router.params.patientId || '';
const isEdit = !!id;
const [form, setForm] = useState<FormState>({ ...initialForm, patient_id: patientIdFromRoute });
const [loading, setLoading] = useState(isEdit);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (isEdit && id) loadRecord();
}, [id]);
const loadRecord = async () => {
setLoading(true);
try {
const r = await doctorApi.getDialysisRecord(id);
setForm({
patient_id: r.patient_id,
dialysis_date: r.dialysis_date || '',
start_time: r.start_time || '',
end_time: r.end_time || '',
dialysis_type: r.dialysis_type || 'HD',
dialysis_duration: r.dialysis_duration != null ? String(r.dialysis_duration) : '',
blood_flow_rate: r.blood_flow_rate != null ? String(r.blood_flow_rate) : '',
dry_weight: r.dry_weight != null ? String(r.dry_weight) : '',
pre_weight: r.pre_weight != null ? String(r.pre_weight) : '',
post_weight: r.post_weight != null ? String(r.post_weight) : '',
pre_bp_systolic: r.pre_bp_systolic != null ? String(r.pre_bp_systolic) : '',
pre_bp_diastolic: r.pre_bp_diastolic != null ? String(r.pre_bp_diastolic) : '',
post_bp_systolic: r.post_bp_systolic != null ? String(r.post_bp_systolic) : '',
post_bp_diastolic: r.post_bp_diastolic != null ? String(r.post_bp_diastolic) : '',
pre_heart_rate: r.pre_heart_rate != null ? String(r.pre_heart_rate) : '',
post_heart_rate: r.post_heart_rate != null ? String(r.post_heart_rate) : '',
ultrafiltration_volume: r.ultrafiltration_volume != null ? String(r.ultrafiltration_volume) : '',
complication_notes: r.complication_notes || '',
});
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const updateField = (key: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async () => {
if (!form.dialysis_date) {
Taro.showToast({ title: '请选择透析日期', icon: 'none' });
return;
}
if (!form.patient_id) {
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
return;
}
setSubmitting(true);
const num = (v: string) => v ? Number(v) : undefined;
const payload = {
patient_id: form.patient_id,
dialysis_date: form.dialysis_date,
start_time: form.start_time || undefined,
end_time: form.end_time || undefined,
dialysis_type: form.dialysis_type,
dialysis_duration: num(form.dialysis_duration),
blood_flow_rate: num(form.blood_flow_rate),
dry_weight: num(form.dry_weight),
pre_weight: num(form.pre_weight),
post_weight: num(form.post_weight),
pre_bp_systolic: num(form.pre_bp_systolic),
pre_bp_diastolic: num(form.pre_bp_diastolic),
post_bp_systolic: num(form.post_bp_systolic),
post_bp_diastolic: num(form.post_bp_diastolic),
pre_heart_rate: num(form.pre_heart_rate),
post_heart_rate: num(form.post_heart_rate),
ultrafiltration_volume: num(form.ultrafiltration_volume),
complication_notes: form.complication_notes || undefined,
};
try {
if (isEdit) {
const { patient_id, ...updateData } = payload;
await doctorApi.updateDialysisRecord(id, updateData, version);
Taro.showToast({ title: '更新成功', icon: 'success' });
} else {
await doctorApi.createDialysisRecord(payload);
Taro.showToast({ title: '创建成功', icon: 'success' });
}
setTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: isEdit ? '更新失败' : '创建失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
if (loading) return <Loading />;
const InputField = ({ label, field, placeholder, type = 'digit' }: {
label: string; field: keyof FormState; placeholder: string; type?: string;
}) => (
<View className='form-row'>
<Text className='form-label'>{label}</Text>
<Input
className='form-input'
type={type as any}
placeholder={placeholder}
value={form[field]}
onInput={(e) => updateField(field, e.detail.value)}
/>
</View>
);
return (
<ScrollView scrollY className='create-page'>
<View className='section'>
<Text className='section-title'></Text>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='date' value={form.dialysis_date} onChange={(e) => updateField('dialysis_date', e.detail.value)}>
<Text className={`form-value ${!form.dialysis_date ? 'placeholder' : ''}`}>
{form.dialysis_date || '请选择日期'}
</Text>
</Picker>
</View>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='time' value={form.start_time} onChange={(e) => updateField('start_time', e.detail.value)}>
<Text className={`form-value ${!form.start_time ? 'placeholder' : ''}`}>
{form.start_time || '请选择时间'}
</Text>
</Picker>
</View>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='time' value={form.end_time} onChange={(e) => updateField('end_time', e.detail.value)}>
<Text className={`form-value ${!form.end_time ? 'placeholder' : ''}`}>
{form.end_time || '请选择时间'}
</Text>
</Picker>
</View>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='selector' range={DIALYSIS_TYPES} value={DIALYSIS_TYPES.indexOf(form.dialysis_type)} onChange={(e) => updateField('dialysis_type', DIALYSIS_TYPES[Number(e.detail.value)])}>
<Text className='form-value'>{form.dialysis_type}</Text>
</Picker>
</View>
<InputField label='透析时长' field='dialysis_duration' placeholder='分钟' type='number' />
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
</View>
<View className='section'>
<Text className='section-title'></Text>
<InputField label='干体重' field='dry_weight' placeholder='kg' />
<InputField label='透前体重' field='pre_weight' placeholder='kg' />
<InputField label='透后体重' field='post_weight' placeholder='kg' />
</View>
<View className='section'>
<Text className='section-title'></Text>
<InputField label='透前收缩压' field='pre_bp_systolic' placeholder='mmHg' type='number' />
<InputField label='透前舒张压' field='pre_bp_diastolic' placeholder='mmHg' type='number' />
<InputField label='透后收缩压' field='post_bp_systolic' placeholder='mmHg' type='number' />
<InputField label='透后舒张压' field='post_bp_diastolic' placeholder='mmHg' type='number' />
<InputField label='透前心率' field='pre_heart_rate' placeholder='bpm' type='number' />
<InputField label='透后心率' field='post_heart_rate' placeholder='bpm' type='number' />
</View>
<View className='section'>
<Text className='section-title'></Text>
<InputField label='超滤量' field='ultrafiltration_volume' placeholder='ml' type='number' />
<View className='form-row form-row--textarea'>
<Text className='form-label'></Text>
<Textarea
className='form-textarea'
placeholder='请输入...'
value={form.complication_notes}
onInput={(e) => updateField('complication_notes', e.detail.value)}
maxlength={500}
/>
</View>
</View>
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
<Text className='submit-btn__text'>{submitting ? '提交中...' : isEdit ? '更新记录' : '创建记录'}</Text>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,155 @@
@import '../../../../styles/variables.scss';
.dialysis-detail {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 200px;
}
.section {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 12px;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.record-header__title {
font-size: 34px;
font-weight: bold;
color: $tx;
font-variant-numeric: tabular-nums;
}
.record-header__status {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
background: $bd-l;
color: $tx3;
&--completed {
background: $pri-l;
color: $pri;
}
&--reviewed {
background: $acc-l;
color: $acc;
}
}
.record-sub {
font-size: 26px;
color: $tx2;
display: block;
}
.review-info {
font-size: 24px;
color: $tx3;
display: block;
margin-top: 8px;
font-variant-numeric: tabular-nums;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26px;
color: $tx2;
}
.detail-value {
font-size: 26px;
color: $tx;
text-align: right;
flex: 1;
margin-left: 24px;
font-variant-numeric: tabular-nums;
}
.error-text {
text-align: center;
padding: 120px 0;
font-size: 28px;
color: $tx3;
}
.actions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.action-btn {
border-radius: $r-sm;
padding: 20px;
text-align: center;
&--primary {
background: $pri;
.action-btn__text {
color: #fff;
}
&:active {
background: $pri-d;
}
}
&--secondary {
background: $card;
border: 1px solid $bd;
.action-btn__text {
color: $pri;
}
}
&--danger {
background: $card;
border: 1px solid $dan;
.action-btn__text {
color: $dan;
}
}
&--disabled {
opacity: 0.5;
}
}
.action-btn__text {
font-size: 28px;
font-weight: 500;
}

View File

@@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
export default function DialysisDetail() {
const router = useRouter();
const id = router.params.id || '';
const [record, setRecord] = useState<doctorApi.DialysisRecord | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) loadRecord();
}, [id]);
const loadRecord = async () => {
setLoading(true);
try {
const r = await doctorApi.getDialysisRecord(id);
setRecord(r);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleReview = async () => {
if (!record) return;
setSubmitting(true);
try {
const updated = await doctorApi.reviewDialysisRecord(id, record.version);
setRecord(updated);
Taro.showToast({ title: '审核完成', icon: 'success' });
} catch {
Taro.showToast({ title: '审核失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const handleComplete = async () => {
if (!record) return;
setSubmitting(true);
try {
const updated = await doctorApi.updateDialysisRecord(id, { status: 'completed' }, record.version);
setRecord(updated);
Taro.showToast({ title: '已标记完成', icon: 'success' });
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!record) return;
const { confirm } = await Taro.showModal({
title: '确认删除',
content: '删除后不可恢复,确定要删除这条记录吗?',
});
if (!confirm) return;
setSubmitting(true);
try {
await doctorApi.deleteDialysisRecord(id, record.version);
Taro.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' });
setSubmitting(false);
}
};
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
if (value == null) return null;
return (
<View className='detail-row'>
<Text className='detail-label'>{label}</Text>
<Text className='detail-value'>{value}{unit || ''}</Text>
</View>
);
};
if (loading) return <Loading />;
if (!record) return <View className='error-text'><Text></Text></View>;
const canComplete = record.status === 'draft';
const canReview = record.status === 'completed';
return (
<ScrollView scrollY className='dialysis-detail'>
{/* 状态头部 */}
<View className='section'>
<View className='record-header'>
<Text className='record-header__title'>{record.dialysis_date}</Text>
<Text className={`record-header__status record-header__status--${record.status}`}>
{record.status === 'draft' ? '草稿' : record.status === 'completed' ? '已完成' : '已审核'}
</Text>
</View>
<Text className='record-sub'>
{(record.dialysis_type === 'HD' ? '血液透析' : record.dialysis_type === 'HDF' ? '血液透析滤过' : record.dialysis_type === 'HF' ? '血液滤过' : record.dialysis_type)}
</Text>
{record.reviewed_at && <Text className='review-info'> {record.reviewed_at}</Text>}
</View>
{/* 基本信息 */}
<View className='section'>
<Text className='section-title'></Text>
<Row label='透析日期' value={record.dialysis_date} />
<Row label='开始时间' value={record.start_time} />
<Row label='结束时间' value={record.end_time} />
<Row label='透析时长' value={record.dialysis_duration} unit=' 分钟' />
<Row label='血流速' value={record.blood_flow_rate} unit=' ml/min' />
<Row label='超滤量' value={record.ultrafiltration_volume} unit=' ml' />
</View>
{/* 体重与血压 */}
<View className='section'>
<Text className='section-title'></Text>
<Row label='干体重' value={record.dry_weight} unit=' kg' />
<Row label='透前体重' value={record.pre_weight} unit=' kg' />
<Row label='透后体重' value={record.post_weight} unit=' kg' />
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
<Row label='透前血压' value={`${record.pre_bp_systolic}/${record.pre_bp_diastolic}`} unit=' mmHg' />
)}
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
<Row label='透后血压' value={`${record.post_bp_systolic}/${record.post_bp_diastolic}`} unit=' mmHg' />
)}
<Row label='透前心率' value={record.pre_heart_rate} unit=' bpm' />
<Row label='透后心率' value={record.post_heart_rate} unit=' bpm' />
</View>
{/* 症状与并发症 */}
{(record.symptoms || record.complication_notes) && (
<View className='section'>
<Text className='section-title'></Text>
{record.symptoms && (
<Row label='症状' value={JSON.stringify(record.symptoms)} />
)}
<Row label='并发症备注' value={record.complication_notes} />
</View>
)}
{/* 操作按钮 */}
<View className='actions'>
{canComplete && (
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleComplete}>
<Text className='action-btn__text'>{submitting ? '处理中...' : '标记完成'}</Text>
</View>
)}
{canReview && (
<View className={`action-btn action-btn--primary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleReview}>
<Text className='action-btn__text'>{submitting ? '审核中...' : '确认审核'}</Text>
</View>
)}
{record.status === 'draft' && (
<View className='action-btn action-btn--secondary' onClick={() => Taro.navigateTo({
url: `/pages/doctor/dialysis/create/index?id=${id}&version=${record.version}`,
})}>
<Text className='action-btn__text'></Text>
</View>
)}
<View className='action-btn action-btn--danger' onClick={handleDelete}>
<Text className='action-btn__text'></Text>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,193 @@
@import '../../../styles/variables.scss';
.dialysis-page {
min-height: 100vh;
background: $bg;
padding-bottom: 120px;
}
.search-bar {
padding: 16px 24px;
background: $card;
}
.search-input {
background: $bg;
border-radius: $r-sm;
padding: 16px 20px;
font-size: 28px;
color: $tx;
}
.tabs {
display: flex;
padding: 0 24px;
background: $card;
border-bottom: 1px solid $bd-l;
}
.tab {
flex: 1;
text-align: center;
padding: 20px 0;
position: relative;
&--active {
.tab-text {
color: $pri;
font-weight: bold;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 25%;
right: 25%;
height: 4px;
background: $pri;
border-radius: 2px;
}
}
}
.tab-text {
font-size: 26px;
color: $tx2;
}
.record-list {
padding: 16px 24px;
}
.record-count {
font-size: 24px;
color: $tx3;
padding: 8px 0 16px;
}
.record-card {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.record-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.type-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
font-weight: 600;
background: $pri-l;
color: $pri-d;
&--hdf {
background: $acc-l;
color: $acc;
}
&--hf {
background: $wrn-l;
color: $wrn;
}
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
background: $bd-l;
color: $tx3;
&--completed {
background: $pri-l;
color: $pri;
}
&--reviewed {
background: $acc-l;
color: $acc;
}
}
.record-card__body {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.record-card__date {
font-size: 26px;
color: $tx;
font-variant-numeric: tabular-nums;
}
.record-card__meta {
font-size: 24px;
color: $tx2;
font-variant-numeric: tabular-nums;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
padding: 16px 0;
gap: 24px;
}
.page-btn {
padding: 12px 24px;
background: $card;
border-radius: $r-sm;
font-size: 26px;
color: $pri;
&--disabled {
opacity: 0.4;
}
}
.page-info {
font-size: 24px;
color: $tx2;
}
.fab {
position: fixed;
right: 32px;
bottom: 120px;
width: 96px;
height: 96px;
border-radius: 48px;
background: $pri;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
z-index: 10;
&:active {
background: $pri-d;
}
}
.fab-text {
font-size: 40px;
color: #fff;
font-weight: bold;
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
const TABS = [
{ key: '', label: '全部' },
{ key: 'draft', label: '草稿' },
{ key: 'completed', label: '已完成' },
{ key: 'reviewed', label: '已审核' },
];
const TYPE_MAP: Record<string, string> = { HD: 'HD', HDF: 'HDF', HF: 'HF' };
export default function DialysisList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [activeTab, setActiveTab] = useState('');
const [records, setRecords] = useState<doctorApi.DialysisRecord[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
if (currentPatientId) loadRecords(1);
}, [currentPatientId, activeTab]);
const loadRecords = async (p: number) => {
setLoading(true);
try {
const res = await doctorApi.listDialysisRecords(currentPatientId, { page: p, page_size: 20 });
setRecords(res.data || []);
setTotal(res.total || 0);
setPage(p);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSearch = async () => {
if (!searchPatient.trim()) return;
setLoading(true);
try {
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
if (res.data && res.data.length > 0) {
setCurrentPatientId(res.data[0].id);
Taro.setNavigationBarTitle({ title: res.data[0].name + '的透析记录' });
} else {
Taro.showToast({ title: '未找到患者', icon: 'none' });
}
} catch {
Taro.showToast({ title: '搜索失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleTab = (key: string) => {
setActiveTab(key);
setPage(1);
};
const filtered = activeTab ? records.filter((r) => r.status === activeTab) : records;
if (loading && records.length === 0) return <Loading />;
return (
<ScrollView scrollY className='dialysis-page'>
{!patientId && (
<View className='search-bar'>
<Input
className='search-input'
placeholder='搜索患者姓名'
value={searchPatient}
onInput={(e) => setSearchPatient(e.detail.value)}
confirmType='search'
onConfirm={handleSearch}
/>
</View>
)}
<View className='tabs'>
{TABS.map((t) => (
<View
key={t.key}
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
onClick={() => handleTab(t.key)}
>
<Text className='tab-text'>{t.label}</Text>
</View>
))}
</View>
{!currentPatientId ? (
<EmptyState text='请搜索并选择患者' />
) : filtered.length === 0 ? (
<EmptyState text='暂无透析记录' />
) : (
<View className='record-list'>
<View className='record-count'><Text> {total} </Text></View>
{filtered.map((r) => (
<View
key={r.id}
className='record-card'
onClick={() => Taro.navigateTo({
url: `/pages/doctor/dialysis/detail/index?id=${r.id}`,
})}
>
<View className='record-card__header'>
<Text className={`type-tag type-tag--${(r.dialysis_type || 'hd').toLowerCase()}`}>
{TYPE_MAP[r.dialysis_type] || r.dialysis_type}
</Text>
<Text className={`status-tag status-tag--${r.status}`}>
{r.status === 'draft' ? '草稿' : r.status === 'completed' ? '已完成' : '已审核'}
</Text>
</View>
<View className='record-card__body'>
<Text className='record-card__date'>{r.dialysis_date}</Text>
{r.dialysis_duration != null && (
<Text className='record-card__meta'> {r.dialysis_duration}</Text>
)}
{r.ultrafiltration_volume != null && (
<Text className='record-card__meta'> {r.ultrafiltration_volume}ml</Text>
)}
</View>
</View>
))}
{total > 20 && (
<View className='pagination'>
<View
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
onClick={() => page > 1 && loadRecords(page - 1)}
>
<Text></Text>
</View>
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
<View
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
onClick={() => page * 20 < total && loadRecords(page + 1)}
>
<Text></Text>
</View>
</View>
)}
</View>
)}
<View
className='fab'
onClick={() => {
if (!currentPatientId) {
Taro.showToast({ title: '请先选择患者', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/pages/doctor/dialysis/create/index?patientId=${currentPatientId}` });
}}
>
<Text className='fab-text'>+</Text>
</View>
</ScrollView>
);
}

View File

@@ -30,6 +30,8 @@ const QUICK_ACTIONS = [
{ label: '患者查询', initial: '查', route: '/pages/doctor/patients/index' },
{ label: '随访记录', initial: '随', route: '/pages/doctor/followup/index' },
{ label: '告警中心', initial: '警', route: '/pages/doctor/alerts/index' },
{ label: '透析记录', initial: '透', route: '/pages/doctor/dialysis/index' },
{ label: '透析处方', initial: '方', route: '/pages/doctor/prescription/index' },
];
export default function DoctorHome() {

View File

@@ -0,0 +1,92 @@
@import '../../../../styles/variables.scss';
.create-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 200px;
}
.section {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.form-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.form-label {
font-size: 26px;
color: $tx2;
flex-shrink: 0;
min-width: 140px;
}
.form-input {
flex: 1;
text-align: right;
font-size: 26px;
color: $tx;
}
.form-value {
font-size: 26px;
color: $tx;
&.placeholder {
color: $tx3;
}
}
.form-textarea {
width: 100%;
margin-top: 12px;
font-size: 26px;
color: $tx;
min-height: 120px;
background: $bg;
border-radius: $r-sm;
padding: 16px;
}
.submit-btn {
background: $pri;
border-radius: $r-sm;
padding: 24px;
text-align: center;
margin-top: 24px;
&:active {
background: $pri-d;
}
&--disabled {
opacity: 0.5;
}
}
.submit-btn__text {
font-size: 30px;
font-weight: bold;
color: #fff;
}

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
interface FormState {
dialyzer_model: string;
membrane_area: string;
dialysate_potassium: string;
dialysate_calcium: string;
dialysate_bicarbonate: string;
anticoagulation_type: string;
anticoagulation_dose: string;
target_ultrafiltration_ml: string;
target_dry_weight: string;
blood_flow_rate: string;
dialysate_flow_rate: string;
frequency_per_week: string;
duration_minutes: string;
vascular_access_type: string;
vascular_access_location: string;
effective_from: string;
effective_to: string;
notes: string;
}
const initialForm: FormState = {
dialyzer_model: '',
membrane_area: '',
dialysate_potassium: '',
dialysate_calcium: '',
dialysate_bicarbonate: '',
anticoagulation_type: '',
anticoagulation_dose: '',
target_ultrafiltration_ml: '',
target_dry_weight: '',
blood_flow_rate: '',
dialysate_flow_rate: '',
frequency_per_week: '',
duration_minutes: '',
vascular_access_type: '',
vascular_access_location: '',
effective_from: '',
effective_to: '',
notes: '',
};
export default function PrescriptionCreate() {
const router = useRouter();
const patientId = router.params.patientId || '';
const [form, setForm] = useState<FormState>(initialForm);
const [submitting, setSubmitting] = useState(false);
const updateField = (key: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const handleSubmit = async () => {
if (!patientId) {
Taro.showToast({ title: '缺少患者信息', icon: 'none' });
return;
}
setSubmitting(true);
const num = (v: string) => v ? Number(v) : undefined;
const payload = {
patient_id: patientId,
dialyzer_model: form.dialyzer_model || undefined,
membrane_area: num(form.membrane_area),
dialysate_potassium: num(form.dialysate_potassium),
dialysate_calcium: num(form.dialysate_calcium),
dialysate_bicarbonate: num(form.dialysate_bicarbonate),
anticoagulation_type: form.anticoagulation_type || undefined,
anticoagulation_dose: form.anticoagulation_dose || undefined,
target_ultrafiltration_ml: num(form.target_ultrafiltration_ml),
target_dry_weight: num(form.target_dry_weight),
blood_flow_rate: num(form.blood_flow_rate),
dialysate_flow_rate: num(form.dialysate_flow_rate),
frequency_per_week: num(form.frequency_per_week),
duration_minutes: num(form.duration_minutes),
vascular_access_type: form.vascular_access_type || undefined,
vascular_access_location: form.vascular_access_location || undefined,
effective_from: form.effective_from || undefined,
effective_to: form.effective_to || undefined,
notes: form.notes || undefined,
};
try {
await doctorApi.createDialysisPrescription(payload);
Taro.showToast({ title: '创建成功', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '创建失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const InputField = ({ label, field, placeholder, type = 'digit' }: {
label: string; field: keyof FormState; placeholder: string; type?: string;
}) => (
<View className='form-row'>
<Text className='form-label'>{label}</Text>
<Input
className='form-input'
type={type as any}
placeholder={placeholder}
value={form[field]}
onInput={(e) => updateField(field, e.detail.value)}
/>
</View>
);
return (
<ScrollView scrollY className='create-page'>
{/* 透析器 */}
<View className='section'>
<Text className='section-title'></Text>
<InputField label='透析器型号' field='dialyzer_model' placeholder='请输入型号' type='text' />
<InputField label='膜面积' field='membrane_area' placeholder='m²' />
</View>
{/* 透析液 */}
<View className='section'>
<Text className='section-title'></Text>
<InputField label='钾浓度' field='dialysate_potassium' placeholder='mmol/L' />
<InputField label='钙浓度' field='dialysate_calcium' placeholder='mmol/L' />
<InputField label='碳酸氢盐' field='dialysate_bicarbonate' placeholder='mmol/L' />
</View>
{/* 抗凝 */}
<View className='section'>
<Text className='section-title'></Text>
<InputField label='抗凝类型' field='anticoagulation_type' placeholder='请输入' type='text' />
<InputField label='抗凝剂量' field='anticoagulation_dose' placeholder='请输入' type='text' />
</View>
{/* 参数 */}
<View className='section'>
<Text className='section-title'></Text>
<InputField label='目标超滤量' field='target_ultrafiltration_ml' placeholder='ml' type='number' />
<InputField label='目标干体重' field='target_dry_weight' placeholder='kg' />
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
<InputField label='透析液流量' field='dialysate_flow_rate' placeholder='ml/min' type='number' />
<InputField label='每周频次' field='frequency_per_week' placeholder='次/周' type='number' />
<InputField label='每次时长' field='duration_minutes' placeholder='分钟' type='number' />
</View>
{/* 血管通路 */}
<View className='section'>
<Text className='section-title'></Text>
<InputField label='通路类型' field='vascular_access_type' placeholder='请输入' type='text' />
<InputField label='通路位置' field='vascular_access_location' placeholder='请输入' type='text' />
</View>
{/* 生效日期 */}
<View className='section'>
<Text className='section-title'></Text>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='date' value={form.effective_from} onChange={(e) => updateField('effective_from', e.detail.value)}>
<Text className={`form-value ${!form.effective_from ? 'placeholder' : ''}`}>
{form.effective_from || '请选择'}
</Text>
</Picker>
</View>
<View className='form-row'>
<Text className='form-label'></Text>
<Picker mode='date' value={form.effective_to} onChange={(e) => updateField('effective_to', e.detail.value)}>
<Text className={`form-value ${!form.effective_to ? 'placeholder' : ''}`}>
{form.effective_to || '请选择'}
</Text>
</Picker>
</View>
</View>
{/* 备注 */}
<View className='section'>
<Text className='section-title'></Text>
<Textarea
className='form-textarea'
placeholder='请输入备注...'
value={form.notes}
onInput={(e) => updateField('notes', e.detail.value)}
maxlength={500}
/>
</View>
<View className={`submit-btn ${submitting ? 'submit-btn--disabled' : ''}`} onClick={handleSubmit}>
<Text className='submit-btn__text'>{submitting ? '提交中...' : '创建处方'}</Text>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,136 @@
@import '../../../../styles/variables.scss';
.prescription-detail {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 200px;
}
.section {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
}
.section-title {
font-size: 28px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 12px;
}
.rx-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.rx-header__title {
font-size: 34px;
font-weight: bold;
color: $tx;
}
.rx-header__status {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 22px;
background: $bd-l;
color: $tx3;
&--active {
background: $acc-l;
color: $acc;
}
}
.rx-sub {
font-size: 26px;
color: $tx2;
display: block;
font-variant-numeric: tabular-nums;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26px;
color: $tx2;
}
.detail-value {
font-size: 26px;
color: $tx;
text-align: right;
flex: 1;
margin-left: 24px;
font-variant-numeric: tabular-nums;
}
.notes-text {
font-size: 26px;
color: $tx;
line-height: 1.6;
}
.error-text {
text-align: center;
padding: 120px 0;
font-size: 28px;
color: $tx3;
}
.actions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.action-btn {
border-radius: $r-sm;
padding: 20px;
text-align: center;
&--secondary {
background: $card;
border: 1px solid $bd;
.action-btn__text {
color: $pri;
}
}
&--danger {
background: $card;
border: 1px solid $dan;
.action-btn__text {
color: $dan;
}
}
&--disabled {
opacity: 0.5;
}
}
.action-btn__text {
font-size: 28px;
font-weight: 500;
}

View File

@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import './index.scss';
export default function PrescriptionDetail() {
const router = useRouter();
const id = router.params.id || '';
const [rx, setRx] = useState<doctorApi.DialysisPrescription | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) loadRx();
}, [id]);
const loadRx = async () => {
setLoading(true);
try {
const data = await doctorApi.getDialysisPrescription(id);
setRx(data);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleDeactivate = async () => {
if (!rx) return;
const { confirm } = await Taro.showModal({
title: '确认停用',
content: '停用后该处方将不再生效,确定停用吗?',
});
if (!confirm) return;
setSubmitting(true);
try {
const updated = await doctorApi.updateDialysisPrescription(id, { status: 'inactive' }, rx.version);
setRx(updated);
Taro.showToast({ title: '已停用', icon: 'success' });
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' });
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!rx) return;
const { confirm } = await Taro.showModal({
title: '确认删除',
content: '删除后不可恢复,确定要删除这条处方吗?',
});
if (!confirm) return;
setSubmitting(true);
try {
await doctorApi.deleteDialysisPrescription(id, rx.version);
Taro.showToast({ title: '已删除', icon: 'success' });
setTimeout(() => Taro.navigateBack(), 1000);
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' });
setSubmitting(false);
}
};
const Row = ({ label, value, unit }: { label: string; value?: string | number | null; unit?: string }) => {
if (value == null) return null;
return (
<View className='detail-row'>
<Text className='detail-label'>{label}</Text>
<Text className='detail-value'>{value}{unit || ''}</Text>
</View>
);
};
if (loading) return <Loading />;
if (!rx) return <View className='error-text'><Text></Text></View>;
return (
<ScrollView scrollY className='prescription-detail'>
{/* 状态头部 */}
<View className='section'>
<View className='rx-header'>
<Text className='rx-header__title'>{rx.dialyzer_model || '透析处方'}</Text>
<Text className={`rx-header__status rx-header__status--${rx.status}`}>
{rx.status === 'active' ? '生效中' : rx.status === 'inactive' ? '已停用' : rx.status}
</Text>
</View>
{(rx.effective_from || rx.effective_to) && (
<Text className='rx-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
)}
</View>
{/* 基本参数 */}
<View className='section'>
<Text className='section-title'></Text>
<Row label='透析器型号' value={rx.dialyzer_model} />
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} unit=' m²' />
<Row label='血流速' value={rx.blood_flow_rate} unit=' ml/min' />
<Row label='透析液流量' value={rx.dialysate_flow_rate} unit=' ml/min' />
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
<Row label='每次时长' value={rx.duration_minutes} unit=' 分钟' />
</View>
{/* 透析液配比 */}
<View className='section'>
<Text className='section-title'></Text>
<Row label='钾浓度' value={rx.dialysate_potassium} unit=' mmol/L' />
<Row label='钙浓度' value={rx.dialysate_calcium} unit=' mmol/L' />
<Row label='碳酸氢盐' value={rx.dialysate_bicarbonate} unit=' mmol/L' />
</View>
{/* 抗凝方案 */}
<View className='section'>
<Text className='section-title'></Text>
<Row label='抗凝类型' value={rx.anticoagulation_type} />
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
</View>
{/* 血管通路 */}
{(rx.vascular_access_type || rx.vascular_access_location) && (
<View className='section'>
<Text className='section-title'></Text>
<Row label='通路类型' value={rx.vascular_access_type} />
<Row label='通路位置' value={rx.vascular_access_location} />
</View>
)}
{/* 超滤目标 */}
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
<View className='section'>
<Text className='section-title'></Text>
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml} unit=' ml' />
<Row label='目标干体重' value={rx.target_dry_weight} unit=' kg' />
</View>
)}
{/* 备注 */}
{rx.notes && (
<View className='section'>
<Text className='section-title'></Text>
<Text className='notes-text'>{rx.notes}</Text>
</View>
)}
{/* 操作按钮 */}
<View className='actions'>
{rx.status === 'active' && (
<View className={`action-btn action-btn--secondary ${submitting ? 'action-btn--disabled' : ''}`} onClick={handleDeactivate}>
<Text className='action-btn__text'></Text>
</View>
)}
<View className='action-btn action-btn--danger' onClick={handleDelete}>
<Text className='action-btn__text'></Text>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,175 @@
@import '../../../styles/variables.scss';
.prescription-page {
min-height: 100vh;
background: $bg;
padding-bottom: 120px;
}
.search-bar {
padding: 16px 24px;
background: $card;
}
.search-input {
background: $bg;
border-radius: $r-sm;
padding: 16px 20px;
font-size: 28px;
color: $tx;
}
.tabs {
display: flex;
padding: 0 24px;
background: $card;
border-bottom: 1px solid $bd-l;
}
.tab {
flex: 1;
text-align: center;
padding: 20px 0;
position: relative;
&--active {
.tab-text {
color: $pri;
font-weight: bold;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 25%;
right: 25%;
height: 4px;
background: $pri;
border-radius: 2px;
}
}
}
.tab-text {
font-size: 26px;
color: $tx2;
}
.prescription-list {
padding: 16px 24px;
}
.prescription-count {
font-size: 24px;
color: $tx3;
padding: 8px 0 16px;
}
.prescription-card {
background: $card;
border-radius: $r;
padding: 24px;
margin-bottom: 16px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.prescription-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.prescription-card__model {
font-size: 28px;
font-weight: bold;
color: $tx;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
background: $bd-l;
color: $tx3;
&--active {
background: $acc-l;
color: $acc;
}
}
.prescription-card__body {
display: flex;
gap: 16px;
margin-bottom: 8px;
}
.prescription-card__meta {
font-size: 24px;
color: $tx2;
font-variant-numeric: tabular-nums;
}
.prescription-card__date {
font-size: 24px;
color: $tx3;
display: block;
font-variant-numeric: tabular-nums;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
padding: 16px 0;
gap: 24px;
}
.page-btn {
padding: 12px 24px;
background: $card;
border-radius: $r-sm;
font-size: 26px;
color: $pri;
&--disabled {
opacity: 0.4;
}
}
.page-info {
font-size: 24px;
color: $tx2;
}
.fab {
position: fixed;
right: 32px;
bottom: 120px;
width: 96px;
height: 96px;
border-radius: 48px;
background: $pri;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-md;
z-index: 10;
&:active {
background: $pri-d;
}
}
.fab-text {
font-size: 40px;
color: #fff;
font-weight: bold;
}

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
const TABS = [
{ key: '', label: '全部' },
{ key: 'active', label: '生效中' },
{ key: 'inactive', label: '已停用' },
];
export default function PrescriptionList() {
const router = useRouter();
const patientId = router.params.patientId || '';
const [searchPatient, setSearchPatient] = useState('');
const [currentPatientId, setCurrentPatientId] = useState(patientId);
const [activeTab, setActiveTab] = useState('');
const [prescriptions, setPrescriptions] = useState<doctorApi.DialysisPrescription[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
loadData(1);
}, [currentPatientId, activeTab]);
const loadData = async (p: number) => {
setLoading(true);
try {
const res = await doctorApi.listDialysisPrescriptions({
patient_id: currentPatientId || undefined,
status: activeTab || undefined,
page: p,
page_size: 20,
});
setPrescriptions(res.data || []);
setTotal(res.total || 0);
setPage(p);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSearch = async () => {
if (!searchPatient.trim()) return;
setLoading(true);
try {
const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 });
if (res.data && res.data.length > 0) {
setCurrentPatientId(res.data[0].id);
} else {
Taro.showToast({ title: '未找到患者', icon: 'none' });
}
} catch {
Taro.showToast({ title: '搜索失败', icon: 'none' });
} finally {
setLoading(false);
}
};
if (loading && prescriptions.length === 0) return <Loading />;
return (
<ScrollView scrollY className='prescription-page'>
{!patientId && (
<View className='search-bar'>
<Input
className='search-input'
placeholder='搜索患者姓名'
value={searchPatient}
onInput={(e) => setSearchPatient(e.detail.value)}
confirmType='search'
onConfirm={handleSearch}
/>
</View>
)}
<View className='tabs'>
{TABS.map((t) => (
<View
key={t.key}
className={`tab ${activeTab === t.key ? 'tab--active' : ''}`}
onClick={() => { setActiveTab(t.key); setPage(1); }}
>
<Text className='tab-text'>{t.label}</Text>
</View>
))}
</View>
{prescriptions.length === 0 ? (
<EmptyState text='暂无透析处方' />
) : (
<View className='prescription-list'>
<View className='prescription-count'><Text> {total} </Text></View>
{prescriptions.map((p) => (
<View
key={p.id}
className='prescription-card'
onClick={() => Taro.navigateTo({
url: `/pages/doctor/prescription/detail/index?id=${p.id}`,
})}
>
<View className='prescription-card__header'>
<Text className='prescription-card__model'>{p.dialyzer_model || '透析处方'}</Text>
<Text className={`status-tag status-tag--${p.status}`}>
{p.status === 'active' ? '生效中' : p.status === 'inactive' ? '已停用' : p.status}
</Text>
</View>
<View className='prescription-card__body'>
{p.frequency_per_week != null && (
<Text className='prescription-card__meta'>{p.frequency_per_week}/</Text>
)}
{p.duration_minutes != null && (
<Text className='prescription-card__meta'>{p.duration_minutes}</Text>
)}
</View>
{(p.effective_from || p.effective_to) && (
<Text className='prescription-card__date'>
{p.effective_from || '...'} ~ {p.effective_to || '...'}
</Text>
)}
</View>
))}
{total > 20 && (
<View className='pagination'>
<View
className={`page-btn ${page <= 1 ? 'page-btn--disabled' : ''}`}
onClick={() => page > 1 && loadData(page - 1)}
>
<Text></Text>
</View>
<Text className='page-info'>{page} / {Math.ceil(total / 20)}</Text>
<View
className={`page-btn ${page * 20 >= total ? 'page-btn--disabled' : ''}`}
onClick={() => page * 20 < total && loadData(page + 1)}
>
<Text></Text>
</View>
</View>
)}
</View>
)}
<View
className='fab'
onClick={() => {
if (!currentPatientId) {
Taro.showToast({ title: '请先选择患者', icon: 'none' });
return;
}
Taro.navigateTo({ url: `/pages/doctor/prescription/create/index?patientId=${currentPatientId}` });
}}
>
<Text className='fab-text'>+</Text>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,118 @@
@import '../../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.detail-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 40px;
}
.detail-card {
background: $card;
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.header-card {
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
}
.detail-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bd-l;
color: $tx3;
&.active {
background: $acc-l;
color: $acc;
}
&.expired {
background: $tx3;
color: $bg;
}
}
.header-sub {
font-size: 26px;
color: $tx2;
display: block;
@include serif-number;
}
.section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26px;
color: $tx2;
flex-shrink: 0;
}
.detail-value {
font-size: 26px;
color: $tx;
text-align: right;
flex: 1;
margin-left: 24px;
@include serif-number;
}
.notes-text {
font-size: 26px;
color: $tx;
line-height: 1.6;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}

View File

@@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { getDialysisPrescription } from '@/services/dialysis';
import type { DialysisPrescription } from '@/services/dialysis';
import Loading from '@/components/Loading';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '生效中', cls: 'active' },
inactive: { label: '已停用', cls: 'inactive' },
expired: { label: '已过期', cls: 'expired' },
};
export default function DialysisPrescriptionDetail() {
const router = useRouter();
const id = router.params.id || '';
const [rx, setRx] = useState<DialysisPrescription | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
setLoading(true);
getDialysisPrescription(id)
.then((data) => setRx(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <View className='detail-page'><Loading /></View>;
if (!rx) return <View className='detail-page'><View className='empty-state'><Text className='empty-text'></Text></View></View>;
const si = STATUS_MAP[rx.status] || { label: rx.status, cls: '' };
const Row = ({ label, value }: { label: string; value?: string | number | null }) => {
if (value == null) return null;
return (
<View className='detail-row'>
<Text className='detail-label'>{label}</Text>
<Text className='detail-value'>{value}</Text>
</View>
);
};
return (
<View className='detail-page'>
{/* 状态头部 */}
<View className='detail-card header-card'>
<View className='header-row'>
<Text className='detail-title'>{rx.dialyzer_model || '透析处方'}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
{(rx.effective_from || rx.effective_to) && (
<Text className='header-sub'>{rx.effective_from || '...'} ~ {rx.effective_to || '...'}</Text>
)}
</View>
{/* 基本参数 */}
<View className='detail-card'>
<Text className='section-title'></Text>
<Row label='透析器型号' value={rx.dialyzer_model} />
<Row label='膜面积' value={rx.membrane_area != null ? `${rx.membrane_area}` : null} />
<Row label='血流速' value={rx.blood_flow_rate != null ? `${rx.blood_flow_rate} ml/min` : null} />
<Row label='透析液流量' value={rx.dialysate_flow_rate != null ? `${rx.dialysate_flow_rate} ml/min` : null} />
<Row label='频率' value={rx.frequency_per_week != null ? `${rx.frequency_per_week} 次/周` : null} />
<Row label='每次时长' value={rx.duration_minutes != null ? `${rx.duration_minutes} 分钟` : null} />
</View>
{/* 透析液配比 */}
<View className='detail-card'>
<Text className='section-title'></Text>
<Row label='钾浓度' value={rx.dialysate_potassium != null ? `${rx.dialysate_potassium} mmol/L` : null} />
<Row label='钙浓度' value={rx.dialysate_calcium != null ? `${rx.dialysate_calcium} mmol/L` : null} />
<Row label='碳酸氢盐浓度' value={rx.dialysate_bicarbonate != null ? `${rx.dialysate_bicarbonate} mmol/L` : null} />
</View>
{/* 抗凝方案 */}
<View className='detail-card'>
<Text className='section-title'></Text>
<Row label='抗凝类型' value={rx.anticoagulation_type} />
<Row label='抗凝剂量' value={rx.anticoagulation_dose} />
</View>
{/* 血管通路 */}
{(rx.vascular_access_type || rx.vascular_access_location) && (
<View className='detail-card'>
<Text className='section-title'></Text>
<Row label='通路类型' value={rx.vascular_access_type} />
<Row label='通路位置' value={rx.vascular_access_location} />
</View>
)}
{/* 超滤与干体重 */}
{(rx.target_ultrafiltration_ml != null || rx.target_dry_weight != null) && (
<View className='detail-card'>
<Text className='section-title'></Text>
<Row label='目标超滤量' value={rx.target_ultrafiltration_ml != null ? `${rx.target_ultrafiltration_ml} ml` : null} />
<Row label='目标干体重' value={rx.target_dry_weight != null ? `${rx.target_dry_weight} kg` : null} />
</View>
)}
{/* 备注 */}
{rx.notes && (
<View className='detail-card'>
<Text className='section-title'></Text>
<Text className='notes-text'>{rx.notes}</Text>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,98 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.dialysis-prescriptions-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 40px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.prescription-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.prescription-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.prescription-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.prescription-model {
font-size: 28px;
font-weight: bold;
color: $tx;
}
.status-tag {
@include tag($bd-l, $tx3);
&.active {
@include tag($acc-l, $acc);
}
&.expired {
@include tag($tx3, $bg);
}
}
.prescription-meta {
display: flex;
gap: 24px;
margin-bottom: 8px;
}
.meta-item {
font-size: 24px;
color: $tx2;
@include serif-number;
}
.prescription-date {
font-size: 24px;
color: $tx3;
display: block;
@include serif-number;
}

View File

@@ -0,0 +1,98 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { listDialysisPrescriptions } from '@/services/dialysis';
import type { DialysisPrescription } from '@/services/dialysis';
import EmptyState from '@/components/EmptyState';
import Loading from '@/components/Loading';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '生效中', cls: 'active' },
inactive: { label: '已停用', cls: 'inactive' },
expired: { label: '已过期', cls: 'expired' },
};
export default function DialysisPrescriptionList() {
const [prescriptions, setPrescriptions] = useState<DialysisPrescription[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async (p: number, append = false) => {
const patientId = Taro.getStorageSync('current_patient_id') || '';
if (!patientId) {
setPrescriptions([]);
return;
}
setLoading(true);
try {
const res = await listDialysisPrescriptions({ patient_id: patientId, page: p, page_size: 20 });
const list = res.data || [];
setPrescriptions(append ? (prev) => [...prev, ...list] : list);
setTotal(res.total);
setPage(p);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useDidShow(() => { fetchData(1); });
usePullDownRefresh(() => {
fetchData(1).finally(() => Taro.stopPullDownRefresh());
});
useReachBottom(() => {
if (!loading && prescriptions.length < total) {
fetchData(page + 1, true);
}
});
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' };
return (
<View className='dialysis-prescriptions-page'>
<Text className='page-title'></Text>
<View className='prescription-list'>
{prescriptions.map((p) => {
const si = statusInfo(p.status);
return (
<View
className='prescription-card'
key={p.id}
onClick={() => Taro.navigateTo({ url: `/pages/pkg-profile/dialysis-prescriptions/detail/index?id=${p.id}` })}
>
<View className='prescription-card-top'>
<Text className='prescription-model'>{p.dialyzer_model || '未指定型号'}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<View className='prescription-meta'>
{p.frequency_per_week != null && (
<Text className='meta-item'>{p.frequency_per_week}/</Text>
)}
{p.duration_minutes != null && (
<Text className='meta-item'>{p.duration_minutes}</Text>
)}
</View>
{(p.effective_from || p.effective_to) && (
<Text className='prescription-date'>
{p.effective_from || '...'} ~ {p.effective_to || '...'}
</Text>
)}
</View>
);
})}
</View>
{prescriptions.length === 0 && !loading && (
<EmptyState text={Taro.getStorageSync('current_patient_id') ? '暂无透析处方' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</View>
);
}

View File

@@ -0,0 +1,119 @@
@import '../../../../styles/variables.scss';
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.detail-page {
min-height: 100vh;
background: $bg;
padding: 24px;
padding-bottom: 40px;
}
.detail-card {
background: $card;
border-radius: $r;
padding: 28px;
margin-bottom: 20px;
box-shadow: $shadow-sm;
}
.header-card {
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
}
.detail-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 34px;
font-weight: bold;
color: $tx;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bd-l;
color: $tx3;
&.completed {
background: $pri-l;
color: $pri;
}
&.reviewed {
background: $acc-l;
color: $acc;
}
}
.header-sub {
font-size: 26px;
color: $tx2;
display: block;
}
.review-info {
font-size: 24px;
color: $tx3;
display: block;
margin-top: 8px;
@include serif-number;
}
.section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
display: block;
margin-bottom: 16px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12px 0;
border-bottom: 1px solid $bd-l;
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-size: 26px;
color: $tx2;
flex-shrink: 0;
}
.detail-value {
font-size: 26px;
color: $tx;
text-align: right;
flex: 1;
margin-left: 24px;
@include serif-number;
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
padding: 120px 0;
}
.empty-text {
font-size: 28px;
color: $tx3;
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { getDialysisRecord } from '@/services/dialysis';
import type { DialysisRecord } from '@/services/dialysis';
import Loading from '@/components/Loading';
import './index.scss';
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
};
const TYPE_MAP: Record<string, string> = {
HD: '血液透析',
HDF: '血液透析滤过',
HF: '血液滤过',
};
export default function DialysisRecordDetail() {
const router = useRouter();
const id = router.params.id || '';
const [record, setRecord] = useState<DialysisRecord | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
setLoading(true);
getDialysisRecord(id)
.then((data) => setRecord(data))
.catch(() => Taro.showToast({ title: '加载失败', icon: 'none' }))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <View className='detail-page'><Loading /></View>;
if (!record) return <View className='detail-page'><View className='empty-state'><Text className='empty-text'></Text></View></View>;
const si = STATUS_MAP[record.status] || { label: record.status, cls: '' };
return (
<View className='detail-page'>
{/* 状态头部 */}
<View className='detail-card header-card'>
<View className='header-row'>
<Text className='detail-title'>{record.dialysis_date}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<Text className='header-sub'>{TYPE_MAP[record.dialysis_type] || record.dialysis_type}</Text>
{record.reviewed_at && <Text className='review-info'> {record.reviewed_at}</Text>}
</View>
{/* 基本信息 */}
<View className='detail-card'>
<Text className='section-title'></Text>
{record.start_time && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.start_time}</Text></View>
)}
{record.end_time && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.end_time}</Text></View>
)}
{record.dialysis_duration != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.dialysis_duration} </Text></View>
)}
{record.blood_flow_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.blood_flow_rate} ml/min</Text></View>
)}
{record.ultrafiltration_volume != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.ultrafiltration_volume} ml</Text></View>
)}
</View>
{/* 体重与血压 */}
<View className='detail-card'>
<Text className='section-title'></Text>
{record.dry_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.dry_weight} kg</Text></View>
)}
{record.pre_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_weight} kg</Text></View>
)}
{record.post_weight != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_weight} kg</Text></View>
)}
{record.pre_bp_systolic != null && record.pre_bp_diastolic != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_bp_systolic}/{record.pre_bp_diastolic} mmHg</Text></View>
)}
{record.post_bp_systolic != null && record.post_bp_diastolic != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_bp_systolic}/{record.post_bp_diastolic} mmHg</Text></View>
)}
{record.pre_heart_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.pre_heart_rate} bpm</Text></View>
)}
{record.post_heart_rate != null && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.post_heart_rate} bpm</Text></View>
)}
</View>
{/* 症状与并发症 */}
{(record.symptoms || record.complication_notes) && (
<View className='detail-card'>
<Text className='section-title'></Text>
{record.symptoms && Object.keys(record.symptoms).length > 0 && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{JSON.stringify(record.symptoms)}</Text></View>
)}
{record.complication_notes && (
<View className='detail-row'><Text className='detail-label'></Text><Text className='detail-value'>{record.complication_notes}</Text></View>
)}
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,111 @@
@import '../../../styles/variables.scss';
@mixin section-title {
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 30px;
font-weight: bold;
color: $tx;
margin-bottom: 20px;
display: block;
}
@mixin tag($bg, $color) {
display: inline-block;
padding: 4px 12px;
border-radius: 8px;
font-size: 20px;
font-weight: 500;
background: $bg;
color: $color;
}
@mixin serif-number {
font-family: 'Georgia', 'Times New Roman', serif;
font-variant-numeric: tabular-nums;
}
.dialysis-records-page {
min-height: 100vh;
background: $bg;
padding: 32px 24px;
padding-bottom: 40px;
}
.page-title {
@include section-title;
padding-left: 4px;
}
.record-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.record-card {
background: $card;
border-radius: $r;
padding: 28px;
box-shadow: $shadow-sm;
&:active {
box-shadow: $shadow-md;
}
}
.record-card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.type-tag {
@include tag($pri-l, $pri-d);
&.hdf {
@include tag($acc-l, $acc);
}
&.hf {
@include tag($wrn-l, $wrn);
}
}
.status-tag {
@include tag($bd-l, $tx3);
&.completed {
@include tag($pri-l, $pri);
}
&.reviewed {
@include tag($acc-l, $acc);
}
}
.record-date {
@include serif-number;
font-size: 26px;
color: $tx2;
display: block;
margin-bottom: 8px;
}
.weight-row {
display: flex;
gap: 24px;
margin-bottom: 4px;
}
.weight-item {
font-size: 24px;
color: $tx2;
@include serif-number;
}
.record-meta {
font-size: 24px;
color: $tx3;
@include serif-number;
}

View File

@@ -0,0 +1,103 @@
import React, { useState, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro';
import { listDialysisRecords } from '@/services/dialysis';
import type { DialysisRecord } from '@/services/dialysis';
import EmptyState from '@/components/EmptyState';
import Loading from '@/components/Loading';
import './index.scss';
const TYPE_MAP: Record<string, { label: string; cls: string }> = {
HD: { label: 'HD', cls: 'hd' },
HDF: { label: 'HDF', cls: 'hdf' },
HF: { label: 'HF', cls: 'hf' },
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
draft: { label: '草稿', cls: 'draft' },
completed: { label: '已完成', cls: 'completed' },
reviewed: { label: '已审核', cls: 'reviewed' },
};
export default function DialysisRecordList() {
const [records, setRecords] = useState<DialysisRecord[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async (p: number, append = false) => {
const patientId = Taro.getStorageSync('current_patient_id') || '';
if (!patientId) {
setRecords([]);
return;
}
setLoading(true);
try {
const res = await listDialysisRecords(patientId, { page: p, page_size: 20 });
const list = res.data || [];
setRecords(append ? (prev) => [...prev, ...list] : list);
setTotal(res.total);
setPage(p);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
}, []);
useDidShow(() => { fetchData(1); });
usePullDownRefresh(() => {
fetchData(1).finally(() => Taro.stopPullDownRefresh());
});
useReachBottom(() => {
if (!loading && records.length < total) {
fetchData(page + 1, true);
}
});
const typeInfo = (t: string) => TYPE_MAP[t] || { label: t, cls: '' };
const statusInfo = (s: string) => STATUS_MAP[s] || { label: s, cls: '' };
return (
<View className='dialysis-records-page'>
<Text className='page-title'></Text>
<View className='record-list'>
{records.map((r) => {
const ti = typeInfo(r.dialysis_type);
const si = statusInfo(r.status);
return (
<View
className='record-card'
key={r.id}
onClick={() => Taro.navigateTo({ url: `/pages/pkg-profile/dialysis-records/detail/index?id=${r.id}` })}
>
<View className='record-card-top'>
<Text className={`type-tag ${ti.cls}`}>{ti.label}</Text>
<Text className={`status-tag ${si.cls}`}>{si.label}</Text>
</View>
<Text className='record-date'>{r.dialysis_date}</Text>
{(r.pre_weight || r.post_weight) && (
<View className='weight-row'>
{r.pre_weight && <Text className='weight-item'> {r.pre_weight}kg</Text>}
{r.post_weight && <Text className='weight-item'> {r.post_weight}kg</Text>}
</View>
)}
{r.dialysis_duration && (
<Text className='record-meta'> {r.dialysis_duration}</Text>
)}
</View>
);
})}
</View>
{records.length === 0 && !loading && (
<EmptyState text={Taro.getStorageSync('current_patient_id') ? '暂无透析记录' : '请先在就诊人管理中选择就诊人'} />
)}
{loading && <Loading />}
</View>
);
}

View File

@@ -12,6 +12,8 @@ const MENU_ITEMS = [
{ label: '我的报告', char: '报', path: '/pages/pkg-profile/reports/index' },
{ label: '我的随访', char: '随', path: '/pages/pkg-profile/followups/index' },
{ label: '用药提醒', char: '药', path: '/pages/pkg-profile/medication/index' },
{ label: '透析记录', char: '透', path: '/pages/pkg-profile/dialysis-records/index' },
{ label: '透析处方', char: '方', path: '/pages/pkg-profile/dialysis-prescriptions/index' },
{ label: '设置', char: '设', path: '/pages/pkg-profile/settings/index' },
];

View File

@@ -0,0 +1,92 @@
import { api } from './request';
// ── Types ─────────────────────────────────────────────
export interface DialysisRecord {
id: string;
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type: string;
symptoms?: Record<string, unknown>;
complication_notes?: string;
status: string;
reviewed_by?: string;
reviewed_at?: string;
created_at: string;
updated_at: string;
version: number;
}
export interface DialysisPrescription {
id: string;
patient_id: string;
dialyzer_model?: string;
membrane_area?: number;
dialysate_potassium?: number;
dialysate_calcium?: number;
dialysate_bicarbonate?: number;
anticoagulation_type?: string;
anticoagulation_dose?: string;
target_ultrafiltration_ml?: number;
target_dry_weight?: number;
blood_flow_rate?: number;
dialysate_flow_rate?: number;
frequency_per_week?: number;
duration_minutes?: number;
vascular_access_type?: string;
vascular_access_location?: string;
effective_from?: string;
effective_to?: string;
status: string;
prescribed_by?: string;
notes?: string;
created_at: string;
updated_at: string;
version: number;
}
// ── Patient-facing API (read-only) ────────────────────
export async function listDialysisRecords(
patientId: string,
params?: { page?: number; page_size?: number },
) {
return api.get<{ data: DialysisRecord[]; total: number }>(
`/health/patients/${patientId}/dialysis-records`,
params,
);
}
export async function getDialysisRecord(id: string) {
return api.get<DialysisRecord>(`/health/dialysis-records/${id}`);
}
export async function listDialysisPrescriptions(params?: {
patient_id?: string;
status?: string;
page?: number;
page_size?: number;
}) {
return api.get<{ data: DialysisPrescription[]; total: number }>(
'/health/dialysis-prescriptions',
params,
);
}
export async function getDialysisPrescription(id: string) {
return api.get<DialysisPrescription>(`/health/dialysis-prescriptions/${id}`);
}

View File

@@ -1,343 +1,11 @@
import { api } from './request';
// doctor.ts — 统一 re-export 入口,保持 `import * as doctorApi` 兼容
// 各领域实现已拆分到 ./doctor/ 子目录
// ── Dashboard ──────────────────────────────────────
export interface DoctorDashboard {
total_patients: number;
active_sessions: number;
unread_messages: number;
pending_follow_ups: number;
today_consultations: number;
pending_lab_review: number;
today_appointments: number;
}
export async function getDashboard() {
return api.get<DoctorDashboard>('/health/doctor/dashboard');
}
// ── Patient (doctor view) ──────────────────────────
export interface PatientItem {
id: string;
name: string;
gender?: string;
birth_date?: string;
phone?: string;
status?: string;
tags?: { id: string; name: string; color?: string }[];
last_visit_date?: string;
version: number;
}
export interface PatientDetail extends PatientItem {
blood_type?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface HealthSummary {
patient_id: string;
latest_vital_signs?: {
record_date: string;
systolic_bp?: number;
diastolic_bp?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
} | null;
latest_lab_report?: {
id: string;
report_date: string;
report_type: string;
abnormal_count?: number;
} | null;
pending_follow_ups?: number;
upcoming_appointments?: number;
}
export interface PatientTag {
id: string;
name: string;
color?: string;
is_system?: boolean;
}
export async function listPatients(params?: {
page?: number;
page_size?: number;
search?: string;
tag_id?: string;
}) {
return api.get<{ data: PatientItem[]; total: number }>('/health/patients', params);
}
export async function getPatient(id: string) {
return api.get<PatientDetail>(`/health/patients/${id}`);
}
export async function getHealthSummary(patientId: string) {
return api.get<HealthSummary>(`/health/patients/${patientId}/health-summary`);
}
export async function listPatientTags() {
return api.get<{ data: PatientTag[]; total: number }>('/health/patient-tags');
}
// ── Consultation (doctor view) ─────────────────────
export interface ConsultationSession {
id: string;
patient_id: string;
patient_name?: string;
doctor_id: string | null;
consultation_type: string;
status: string;
subject: string | null;
last_message: string | null;
last_message_at: string | null;
unread_count_doctor?: number;
created_at: string;
}
export interface ConsultationMessage {
id: string;
session_id: string;
sender_id: string;
sender_role: string;
content_type: string;
content: string;
created_at: string;
}
export async function listSessions(params?: {
page?: number;
page_size?: number;
status?: string;
}) {
return api.get<{ data: ConsultationSession[]; total: number }>(
'/health/consultation-sessions',
params,
);
}
export async function getSession(id: string) {
return api.get<ConsultationSession>(`/health/consultation-sessions/${id}`);
}
export async function listMessages(sessionId: string, params?: { page?: number; page_size?: number; after_id?: string }) {
return api.get<{ data: ConsultationMessage[]; total: number }>(
`/health/consultation-sessions/${sessionId}/messages`,
params,
);
}
export async function sendMessage(sessionId: string, content: string, contentType = 'text') {
return api.post<ConsultationMessage>('/health/consultation-messages', {
session_id: sessionId,
content_type: contentType,
content,
});
}
export async function markSessionRead(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/read`);
}
export async function closeSession(sessionId: string) {
return api.put<void>(`/health/consultation-sessions/${sessionId}/close`);
}
// ── Follow-up (doctor view) ────────────────────────
export interface FollowUpTask {
id: string;
patient_id: string;
patient_name?: string;
assigned_to?: string;
follow_up_type: string;
planned_date: string;
content_template?: string;
status: string;
created_at: string;
version: number;
}
export interface FollowUpRecord {
id: string;
task_id: string;
executed_by?: string;
executed_date: string;
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
created_at: string;
}
export async function listFollowUpTasks(params?: {
page?: number;
page_size?: number;
status?: string;
patient_id?: string;
}) {
return api.get<{ data: FollowUpTask[]; total: number }>('/health/follow-up-tasks', params);
}
export async function getFollowUpTask(id: string) {
return api.get<FollowUpTask>(`/health/follow-up-tasks/${id}`);
}
export async function updateFollowUpTask(id: string, data: Record<string, unknown>, version: number) {
return api.put<FollowUpTask>(`/health/follow-up-tasks/${id}`, { ...data, version });
}
export async function createFollowUpRecord(taskId: string, data: {
result?: string;
patient_condition?: string;
medical_advice?: string;
next_follow_up_date?: string;
}) {
return api.post<FollowUpRecord>(`/health/follow-up-tasks/${taskId}/records`, { task_id: taskId, ...data });
}
export async function listFollowUpRecords(params?: { task_id?: string; page?: number }) {
return api.get<{ data: FollowUpRecord[]; total: number }>('/health/follow-up-records', params);
}
// ── Lab Report (doctor view) ───────────────────────
export interface LabReportItem {
id: string;
report_date: string;
report_type: string;
status: string;
abnormal_count?: number;
reviewed_by?: string;
reviewed_at?: string;
doctor_notes?: string;
version: number;
}
export interface LabReportDetail extends LabReportItem {
items?: {
name: string;
value: number;
unit?: string;
reference_min?: number;
reference_max?: number;
is_abnormal?: boolean;
}[];
image_urls?: string[];
}
export async function listLabReports(patientId: string, params?: { page?: number; page_size?: number }) {
return api.get<{ data: LabReportItem[]; total: number }>(
`/health/patients/${patientId}/lab-reports`,
params,
);
}
export async function getLabReport(patientId: string, reportId: string) {
return api.get<LabReportDetail>(`/health/patients/${patientId}/lab-reports/${reportId}`);
}
export async function reviewLabReport(
patientId: string,
reportId: string,
data: { doctor_notes?: string; version: number },
) {
return api.put<LabReportDetail>(
`/health/patients/${patientId}/lab-reports/${reportId}/review`,
data,
);
}
// ── Appointments (doctor view) ─────────────────────
export async function listAppointments(params?: {
page?: number;
page_size?: number;
status?: string;
date?: string;
}) {
return api.get<{ data: any[]; total: number }>('/health/appointments', params);
}
// ── Statistics ─────────────────────────────────────
export interface PatientStats {
total_patients: number;
new_this_month: number;
new_this_week: number;
active_this_month: number;
}
export interface ConsultationStats {
total_sessions: number;
pending_reply: number;
avg_response_time_minutes?: number | null;
this_month: number;
}
export interface FollowUpStats {
total_tasks: number;
completed: number;
pending: number;
overdue: number;
completion_rate?: number;
}
export async function getPatientStats() {
return api.get<PatientStats>('/health/admin/statistics/patients');
}
export async function getConsultationStats() {
return api.get<ConsultationStats>('/health/admin/statistics/consultations');
}
export async function getFollowUpStats() {
return api.get<FollowUpStats>('/health/admin/statistics/follow-ups');
}
// ── Alerts (doctor view) ────────────────────────────
export interface Alert {
id: string;
patient_id: string;
rule_id: string;
severity: string;
title: string;
detail?: Record<string, unknown>;
status: string;
acknowledged_by?: string;
acknowledged_at?: string;
resolved_at?: string;
created_at: string;
version: number;
}
export async function listAlerts(params?: {
patient_id?: string;
status?: string;
page?: number;
page_size?: number;
}) {
return api.get<{ data: Alert[]; total: number }>('/health/alerts', params);
}
export async function acknowledgeAlert(id: string, version: number) {
return api.put<Alert>(`/health/alerts/${id}/acknowledge`, { version });
}
export async function dismissAlert(id: string, version: number) {
return api.put<Alert>(`/health/alerts/${id}/dismiss`, { version });
}
export async function resolveAlert(id: string, version: number) {
return api.put<Alert>(`/health/alerts/${id}/resolve`, { version });
}
export * from './doctor/dashboard';
export * from './doctor/patient';
export * from './doctor/consultation';
export * from './doctor/followup';
export * from './doctor/labReport';
export * from './doctor/alerts';
export * from './doctor/appointment';
export * from './doctor/dialysis';

View File

@@ -0,0 +1,141 @@
import { api } from '../request';
import type { DialysisRecord, DialysisPrescription } from '../dialysis';
// Re-export types for convenience
export type { DialysisRecord, DialysisPrescription };
// ── Request types ─────────────────────────────────────
export interface CreateDialysisRecordReq {
patient_id: string;
dialysis_date: string;
start_time?: string;
end_time?: string;
dry_weight?: number;
pre_weight?: number;
post_weight?: number;
pre_bp_systolic?: number;
pre_bp_diastolic?: number;
post_bp_systolic?: number;
post_bp_diastolic?: number;
pre_heart_rate?: number;
post_heart_rate?: number;
ultrafiltration_volume?: number;
dialysis_duration?: number;
blood_flow_rate?: number;
dialysis_type?: string;
symptoms?: Record<string, unknown>;
complication_notes?: string;
}
export type UpdateDialysisRecordReq = Omit<CreateDialysisRecordReq, 'patient_id'>;
export interface CreateDialysisPrescriptionReq {
patient_id: string;
dialyzer_model?: string;
membrane_area?: number;
dialysate_potassium?: number;
dialysate_calcium?: number;
dialysate_bicarbonate?: number;
anticoagulation_type?: string;
anticoagulation_dose?: string;
target_ultrafiltration_ml?: number;
target_dry_weight?: number;
blood_flow_rate?: number;
dialysate_flow_rate?: number;
frequency_per_week?: number;
duration_minutes?: number;
vascular_access_type?: string;
vascular_access_location?: string;
effective_from?: string;
effective_to?: string;
notes?: string;
}
export type UpdateDialysisPrescriptionReq = Omit<CreateDialysisPrescriptionReq, 'patient_id'>;
export interface DialysisStatistics {
total_records: number;
this_month: number;
type_distribution: Array<{ name: string; value: number }>;
complication_rate: number;
avg_ultrafiltration?: number;
avg_duration?: number;
pending_review: number;
}
// ── Dialysis Records CRUD ─────────────────────────────
export async function listDialysisRecords(
patientId: string,
params?: { page?: number; page_size?: number },
) {
return api.get<{ data: DialysisRecord[]; total: number }>(
`/health/patients/${patientId}/dialysis-records`,
params,
);
}
export async function getDialysisRecord(id: string) {
return api.get<DialysisRecord>(`/health/dialysis-records/${id}`);
}
export async function createDialysisRecord(data: CreateDialysisRecordReq) {
return api.post<DialysisRecord>('/health/dialysis-records', data);
}
export async function updateDialysisRecord(
id: string,
data: UpdateDialysisRecordReq,
version: number,
) {
return api.put<DialysisRecord>(`/health/dialysis-records/${id}`, { ...data, version });
}
export async function deleteDialysisRecord(id: string, version: number) {
return api.delete<void>(`/health/dialysis-records/${id}`, { version });
}
export async function reviewDialysisRecord(id: string, version: number) {
return api.put<DialysisRecord>(`/health/dialysis-records/${id}/review`, { version });
}
// ── Dialysis Prescriptions CRUD ───────────────────────
export async function listDialysisPrescriptions(params?: {
patient_id?: string;
status?: string;
page?: number;
page_size?: number;
}) {
return api.get<{ data: DialysisPrescription[]; total: number }>(
'/health/dialysis-prescriptions',
params,
);
}
export async function getDialysisPrescription(id: string) {
return api.get<DialysisPrescription>(`/health/dialysis-prescriptions/${id}`);
}
export async function createDialysisPrescription(data: CreateDialysisPrescriptionReq) {
return api.post<DialysisPrescription>('/health/dialysis-prescriptions', data);
}
export async function updateDialysisPrescription(
id: string,
data: UpdateDialysisPrescriptionReq,
version: number,
) {
return api.put<DialysisPrescription>(`/health/dialysis-prescriptions/${id}`, { ...data, version });
}
export async function deleteDialysisPrescription(id: string, version: number) {
return api.delete<void>(`/health/dialysis-prescriptions/${id}`, { version });
}
// ── Statistics ────────────────────────────────────────
export async function getDialysisStats() {
return api.get<DialysisStatistics>('/health/admin/statistics/dialysis');
}

View File

@@ -141,5 +141,5 @@ export const api = {
post: <T>(path: string, data?: unknown) => request<T>('POST', path, data),
put: <T>(path: string, data?: unknown) => request<T>('PUT', path, data),
delete: <T>(path: string) => request<T>('DELETE', path),
delete: <T>(path: string, data?: unknown) => request<T>('DELETE', path, data),
};