feat(miniprogram): 医护端小程序页面 — 8 页面覆盖患者/咨询/随访/报告
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

Iteration 2 医护端前端核心页面:

- 新增 doctor.ts service 层(仪表盘/患者/咨询/随访/报告 API)
- 升级医生首页:接入真实仪表盘数据 + 快捷操作入口
- 患者管理:搜索 + 标签筛选 + 详情页(基本信息/过敏史/健康概览)
- 咨询回复:会话列表 + 状态筛选 + 聊天详情 + 发送消息 + 关闭会话
- 随访管理:任务列表 + 状态筛选 + 详情 + 填写随访记录
- 报告解读:化验报告列表 + 异常高亮 + 指标表格 + 医生审核注释
- 修复 login 页面重复解构
- 注册 8 个新页面路由到 app.config.ts
This commit is contained in:
iven
2026-04-26 13:32:08 +08:00
parent a0b72b0f73
commit 3723cd93c0
21 changed files with 2795 additions and 28 deletions

View File

@@ -0,0 +1,199 @@
.patient-detail {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.section {
background: #fff;
border-radius: 16px;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 28px;
font-weight: 600;
color: #0f172a;
display: block;
margin-bottom: 20px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 22px;
color: #94a3b8;
}
.info-value {
font-size: 28px;
color: #0f172a;
font-weight: 500;
}
.warning-card {
background: #fef3c7;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.warning-label {
font-size: 22px;
color: #b45309;
font-weight: 600;
display: block;
margin-bottom: 8px;
}
.warning-text {
font-size: 26px;
color: #92400e;
}
.info-block {
margin-bottom: 12px;
}
.info-block-label {
font-size: 22px;
color: #94a3b8;
display: block;
margin-bottom: 8px;
}
.info-block-text {
font-size: 26px;
color: #334155;
line-height: 1.6;
}
.vitals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.vital-item {
background: #f0f9ff;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.vital-value {
font-size: 36px;
font-weight: 700;
color: #0891b2;
display: block;
margin-bottom: 4px;
}
.vital-label {
font-size: 22px;
color: #64748b;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
}
.stat-label {
font-size: 26px;
color: #475569;
}
.stat-value {
font-size: 26px;
font-weight: 600;
color: #0f172a;
&--warn {
color: #f59e0b;
}
}
.lab-item {
padding: 20px 0;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: none;
}
&:active {
background: #f8fafc;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
&__type {
font-size: 26px;
font-weight: 500;
color: #0f172a;
}
&__date {
font-size: 24px;
color: #94a3b8;
}
&__abnormal {
font-size: 24px;
color: #ef4444;
font-weight: 500;
}
}
.action-buttons {
display: flex;
gap: 16px;
}
.action-btn {
flex: 1;
text-align: center;
padding: 20px;
border-radius: 12px;
background: #0891b2;
color: #fff;
font-size: 26px;
font-weight: 500;
&:active {
opacity: 0.85;
}
text {
color: #fff;
}
}
.error-text {
text-align: center;
padding: 80px 32px;
color: #94a3b8;
font-size: 28px;
}

View File

@@ -0,0 +1,156 @@
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 PatientDetail() {
const router = useRouter();
const patientId = router.params.id || '';
const [patient, setPatient] = useState<doctorApi.PatientDetail | null>(null);
const [summary, setSummary] = useState<doctorApi.HealthSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (patientId) loadData();
}, [patientId]);
const loadData = async () => {
setLoading(true);
try {
const [p, s] = await Promise.all([
doctorApi.getPatient(patientId),
doctorApi.getHealthSummary(patientId),
]);
setPatient(p);
setSummary(s);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const getGenderLabel = (g?: string) => (g === 'male' ? '男' : g === 'female' ? '女' : g || '-');
const calcAge = (bd?: string) => {
if (!bd) return '-';
const diff = Date.now() - new Date(bd).getTime();
return Math.floor(diff / (365.25 * 24 * 3600 * 1000));
};
if (loading) return <Loading />;
if (!patient) return <View className='error-text'><Text></Text></View>;
return (
<ScrollView scrollY className='patient-detail'>
{/* 基本信息 */}
<View className='section'>
<View className='section-header'>
<Text className='section-title'></Text>
</View>
<View className='info-grid'>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{patient.name}</Text></View>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{getGenderLabel(patient.gender)}</Text></View>
<View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{calcAge(patient.birth_date)}</Text></View>
{patient.blood_type && <View className='info-item'><Text className='info-label'></Text><Text className='info-value'>{patient.blood_type}</Text></View>}
</View>
</View>
{/* 医疗信息 */}
{(patient.allergy_history || patient.medical_history_summary) && (
<View className='section'>
<Text className='section-title'></Text>
{patient.allergy_history && (
<View className='warning-card'>
<Text className='warning-label'></Text>
<Text className='warning-text'>{patient.allergy_history}</Text>
</View>
)}
{patient.medical_history_summary && (
<View className='info-block'>
<Text className='info-block-label'></Text>
<Text className='info-block-text'>{patient.medical_history_summary}</Text>
</View>
)}
</View>
)}
{/* 健康摘要 */}
{summary && (
<View className='section'>
<Text className='section-title'></Text>
{summary.latest_vital_signs && (
<View className='vitals-grid'>
{summary.latest_vital_signs.systolic_bp != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.systolic_bp}/{summary.latest_vital_signs.diastolic_bp}</Text>
<Text className='vital-label'> mmHg</Text>
</View>
)}
{summary.latest_vital_signs.heart_rate != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.heart_rate}</Text>
<Text className='vital-label'> bpm</Text>
</View>
)}
{summary.latest_vital_signs.weight != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.weight}</Text>
<Text className='vital-label'> kg</Text>
</View>
)}
{summary.latest_vital_signs.blood_sugar != null && (
<View className='vital-item'>
<Text className='vital-value'>{summary.latest_vital_signs.blood_sugar}</Text>
<Text className='vital-label'> mmol/L</Text>
</View>
)}
</View>
)}
{summary.pending_follow_ups != null && summary.pending_follow_ups > 0 && (
<View className='stat-row'>
<Text className='stat-label'>访</Text>
<Text className='stat-value stat-value--warn'>{summary.pending_follow_ups} </Text>
</View>
)}
</View>
)}
{/* 近期化验 */}
{summary?.recent_lab_reports && summary.recent_lab_reports.length > 0 && (
<View className='section'>
<Text className='section-title'></Text>
{summary.recent_lab_reports.map((r) => (
<View
key={r.id}
className='lab-item'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/report/detail/index?patientId=${patientId}&id=${r.id}` })}
>
<View className='lab-item__header'>
<Text className='lab-item__type'>{r.report_type}</Text>
<Text className='lab-item__date'>{r.report_date}</Text>
</View>
{r.abnormal_count > 0 && (
<Text className='lab-item__abnormal'>{r.abnormal_count} </Text>
)}
</View>
))}
</View>
)}
{/* 快捷操作 */}
<View className='section'>
<Text className='section-title'></Text>
<View className='action-buttons'>
<View className='action-btn' onClick={() => Taro.navigateTo({ url: `/pages/doctor/report/index?patientId=${patientId}` })}>
<Text></Text>
</View>
<View className='action-btn' onClick={() => Taro.navigateTo({ url: `/pages/doctor/followup/index?patientId=${patientId}` })}>
<Text>访</Text>
</View>
</View>
</View>
</ScrollView>
);
}

View File

@@ -0,0 +1,140 @@
.patient-list-page {
min-height: 100vh;
background: #f0f4f8;
padding: 24px;
padding-bottom: 120px;
}
.search-bar {
margin-bottom: 20px;
.search-input {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
font-size: 28px;
width: 100%;
box-sizing: border-box;
}
}
.tag-filter {
white-space: nowrap;
margin-bottom: 20px;
width: 100%;
}
.tag-chip {
display: inline-block;
padding: 10px 24px;
border-radius: 20px;
background: #e2e8f0;
font-size: 24px;
color: #475569;
margin-right: 16px;
&.active {
background: #0891b2;
color: #fff;
}
}
.patient-count {
margin-bottom: 16px;
text {
font-size: 24px;
color: #94a3b8;
}
}
.patient-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.patient-card {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
&:active {
background: #f8fafc;
}
&__header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
&__name {
font-size: 30px;
font-weight: 600;
color: #0f172a;
margin-right: 16px;
}
&__meta {
font-size: 24px;
color: #64748b;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
&__status {
font-size: 22px;
padding: 4px 12px;
border-radius: 8px;
&--active {
background: #dcfce7;
color: #16a34a;
}
&--inactive {
background: #f1f5f9;
color: #94a3b8;
}
}
}
.patient-tag {
padding: 4px 14px;
border-radius: 12px;
background: #e0f2fe;
&__text {
font-size: 22px;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 24px;
margin-top: 32px;
&__btn {
font-size: 26px;
color: #0891b2;
padding: 12px 24px;
&.disabled {
color: #cbd5e1;
}
}
&__info {
font-size: 24px;
color: #64748b;
}
}

View File

@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react';
import { View, Text, Input, ScrollView } from '@tarojs/components';
import Taro from '@tarojs/taro';
import * as doctorApi from '@/services/doctor';
import Loading from '@/components/Loading';
import EmptyState from '@/components/EmptyState';
import './index.scss';
export default function PatientList() {
const [patients, setPatients] = useState<doctorApi.PatientItem[]>([]);
const [tags, setTags] = useState<doctorApi.PatientTag[]>([]);
const [activeTag, setActiveTag] = useState<string>('');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
useEffect(() => {
loadTags();
}, []);
useEffect(() => {
loadPatients();
}, [page, activeTag]);
const loadTags = async () => {
try {
const res = await doctorApi.listPatientTags();
setTags(res.data || []);
} catch { /* ignore */ }
};
const loadPatients = async () => {
setLoading(true);
try {
const res = await doctorApi.listPatients({
page,
page_size: 20,
search: search || undefined,
tag_id: activeTag || undefined,
});
setPatients(res.data || []);
setTotal(res.total || 0);
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' });
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setPage(1);
loadPatients();
};
const handleTagFilter = (tagId: string) => {
setActiveTag(tagId === activeTag ? '' : tagId);
setPage(1);
};
const getGenderLabel = (gender?: string) => {
if (!gender) return '';
return gender === 'male' ? '男' : gender === 'female' ? '女' : gender;
};
const calcAge = (birthDate?: string) => {
if (!birthDate) return '';
const birth = new Date(birthDate);
const now = new Date();
let age = now.getFullYear() - birth.getFullYear();
if (now.getMonth() < birth.getMonth() ||
(now.getMonth() === birth.getMonth() && now.getDate() < birth.getDate())) {
age--;
}
return `${age}`;
};
if (loading && patients.length === 0) return <Loading />;
return (
<ScrollView scrollY className='patient-list-page'>
<View className='search-bar'>
<Input
className='search-input'
placeholder='搜索患者姓名/手机号'
value={search}
onInput={(e) => setSearch(e.detail.value)}
confirmType='search'
onConfirm={handleSearch}
/>
</View>
{tags.length > 0 && (
<ScrollView scrollX className='tag-filter'>
<View
className={`tag-chip ${!activeTag ? 'active' : ''}`}
onClick={() => handleTagFilter('')}
>
<Text></Text>
</View>
{tags.map((tag) => (
<View
key={tag.id}
className={`tag-chip ${activeTag === tag.id ? 'active' : ''}`}
style={activeTag === tag.id && tag.color ? `background: ${tag.color}; color: #fff` : ''}
onClick={() => handleTagFilter(tag.id)}
>
<Text>{tag.name}</Text>
</View>
))}
</ScrollView>
)}
<View className='patient-count'>
<Text> {total} </Text>
</View>
{patients.length === 0 ? (
<EmptyState text='暂无患者数据' />
) : (
<View className='patient-cards'>
{patients.map((p) => (
<View
key={p.id}
className='patient-card'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/patients/detail/index?id=${p.id}` })}
>
<View className='patient-card__header'>
<Text className='patient-card__name'>{p.name}</Text>
<Text className='patient-card__meta'>
{getGenderLabel(p.gender)} {calcAge(p.birth_date)}
</Text>
</View>
{p.tags && p.tags.length > 0 && (
<View className='patient-card__tags'>
{p.tags.map((t) => (
<View
key={t.id}
className='patient-tag'
style={t.color ? `background: ${t.color}20; color: ${t.color}` : ''}
>
<Text className='patient-tag__text'>{t.name}</Text>
</View>
))}
</View>
)}
{p.status && (
<Text className={`patient-card__status patient-card__status--${p.status}`}>
{p.status === 'active' ? '活跃' : p.status === 'inactive' ? '非活跃' : p.status}
</Text>
)}
</View>
))}
</View>
)}
{total > 20 && (
<View className='pagination'>
<Text
className={`pagination__btn ${page <= 1 ? 'disabled' : ''}`}
onClick={() => page > 1 && setPage(page - 1)}
>
</Text>
<Text className='pagination__info'>{page} / {Math.ceil(total / 20)}</Text>
<Text
className={`pagination__btn ${page >= Math.ceil(total / 20) ? 'disabled' : ''}`}
onClick={() => page < Math.ceil(total / 20) && setPage(page + 1)}
>
</Text>
</View>
)}
</ScrollView>
);
}