feat(miniprogram): 实现小程序透析模块 — 患者端查看 + 医护端录入/审阅
审计后续 H1: 补齐小程序端透析功能,对接后端 12 个 API 路由。 新增内容: - 患者端: 透析记录列表/详情 + 透析处方列表/详情(只读,4 页面) - 医护端: 透析记录列表/详情/创建 + 处方列表/详情/创建(6 页面) - Service 层: dialysis.ts(患者端只读)+ doctor/dialysis.ts(医护端 CRUD) - 集成入口: 医生工作台快捷操作 + 患者"我的"菜单 + 路由注册 - 基础设施: api.delete 扩展支持 data 参数(后端 delete 需要 version)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
196
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal file
196
apps/miniprogram/src/pages/doctor/prescription/create/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal file
136
apps/miniprogram/src/pages/doctor/prescription/detail/index.scss
Normal 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;
|
||||
}
|
||||
161
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal file
161
apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal file
175
apps/miniprogram/src/pages/doctor/prescription/index.scss
Normal 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;
|
||||
}
|
||||
163
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal file
163
apps/miniprogram/src/pages/doctor/prescription/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user