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

@@ -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>
);
}