fix(web,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

## 前端修复
- 修复 9 个 TypeScript 编译错误(未使用变量/undefined 守卫/vitest 类型)
- 重写 E2E auth fixture 使用真实 API 登录替代 mock token
- 更新 E2E 测试选择器适配当前 UI 布局
- Playwright 改为串行执行避免 token 唯一约束冲突
- E2E 测试从 0/10 通过提升到 10/10 通过

## 小程序接口一致性修复(P0-P3)
- P0: consultation.ts type→consultation_type, unread_count→unread_count_patient
- P0: followup.ts task_type→follow_up_type, due_date→planned_date, description→content_template
- P1: appointment.ts calendarView 展平嵌套结构, available_count 计算 max-current
- P1: doctor.ts HealthSummary 适配后台实际返回结构
- P2: doctor.ts PatientStats/ConsultationStats/FollowUpStats 字段名对齐
- P3: article.ts 新增 buildCategoryTree 工具函数
This commit is contained in:
iven
2026-04-27 22:09:21 +08:00
parent e1d9f97d79
commit c53f5625bc
21 changed files with 323 additions and 214 deletions

View File

@@ -10,12 +10,12 @@ import WeekCalendar from '../../../components/WeekCalendar';
import './index.scss';
const DEPARTMENTS = [
{ label: '内科', icon: '🫀' },
{ label: '外科', icon: '🔪' },
{ label: '妇科', icon: '👩‍⚕️' },
{ label: '儿科', icon: '👶' },
{ label: '体检中心', icon: '🏥' },
{ label: '中医科', icon: '🌿' },
{ label: '内科', initial: '' },
{ label: '外科', initial: '' },
{ label: '妇科', initial: '' },
{ label: '儿科', initial: '' },
{ label: '体检中心', initial: '' },
{ label: '中医科', initial: '' },
];
interface DoctorItem {
@@ -83,14 +83,13 @@ export default function AppointmentCreate() {
const onSelectDate = useCallback((date: string) => {
setAppointmentDate(date);
setTimeSlot('');
// 从排班数据中提取时段
const daySlots = schedules
.filter((s: any) => (s.date || s.appointment_date) === date)
.map((s: any) => ({
start_time: s.start_time || '',
end_time: s.end_time || '',
label: `${s.start_time || ''}-${s.end_time || ''}`,
available_count: s.available_count ?? (s.max_patients ?? 10),
available_count: s.available_count ?? (s.max_appointments - (s.current_appointments || 0)),
}));
setTimeSlots(daySlots);
}, [schedules]);
@@ -120,7 +119,6 @@ export default function AppointmentCreate() {
});
Taro.showToast({ title: '预约成功', icon: 'success' });
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
// 订阅消息引导
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
if (tmplId) {
try {
@@ -168,7 +166,9 @@ export default function AppointmentCreate() {
key={dept.label}
onClick={() => onSelectDept(dept.label)}
>
<Text className='dept-icon'>{dept.icon}</Text>
<View className='dept-initial-circle'>
<Text className='dept-initial-text'>{dept.initial}</Text>
</View>
<Text className='dept-label'>{dept.label}</Text>
</View>
))}
@@ -181,7 +181,9 @@ export default function AppointmentCreate() {
<View className='step-content'>
<Text className='step-title'>{department} - </Text>
{doctors.length === 0 ? (
<View className='empty-hint'><Text className='empty-text'></Text></View>
<View className='empty-hint'>
<Text className='empty-text'></Text>
</View>
) : (
<View className='doctor-list'>
{doctors.map((doc) => (
@@ -190,13 +192,19 @@ export default function AppointmentCreate() {
key={doc.id}
onClick={() => onSelectDoctor(doc)}
>
<View className='doctor-avatar'><Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text></View>
<View className='doctor-avatar'>
<Text className='doctor-avatar-text'>{doc.name.charAt(0)}</Text>
</View>
<View className='doctor-detail'>
<Text className='doctor-name'>{doc.name}</Text>
<Text className='doctor-title'>{doc.title || '医生'}</Text>
{doc.specialty && <Text className='doctor-specialty'>{doc.specialty}</Text>}
</View>
{selectedDoctor?.id === doc.id && <Text className='doctor-check'>&#10003;</Text>}
{selectedDoctor?.id === doc.id && (
<View className='doctor-check'>
<Text className='doctor-check-text'>&#10003;</Text>
</View>
)}
</View>
))}
</View>
@@ -208,9 +216,20 @@ export default function AppointmentCreate() {
{currentStep === 2 && (
<View className='step-content'>
<Text className='step-title'></Text>
<View className='form-group'>
<Text className='form-label'></Text>
<View className='form-static'><Text className='form-static-text'>{selectedDoctor?.name} - {department}</Text></View>
<View className='confirm-card'>
<View className='confirm-row'>
<View className='confirm-icon-wrap'>
<Text className='confirm-icon-serif'></Text>
</View>
<View className='confirm-info'>
<Text className='confirm-label'></Text>
<Text className='confirm-value'>{selectedDoctor?.name}</Text>
</View>
<View className='confirm-dept-tag'>
<Text className='confirm-dept-text'>{department}</Text>
</View>
</View>
</View>
<WeekCalendar
@@ -221,7 +240,7 @@ export default function AppointmentCreate() {
{appointmentDate && timeSlots.length > 0 && (
<View className='slot-section'>
<Text className='form-label'></Text>
<Text className='slot-section-title'></Text>
<View className='slot-grid'>
{timeSlots.map((slot) => (
<View
@@ -230,7 +249,9 @@ export default function AppointmentCreate() {
onClick={slot.available_count > 0 ? () => setTimeSlot(slot.label) : undefined}
>
<Text className='slot-time'>{slot.label}</Text>
<Text className='slot-count'>{slot.available_count > 0 ? `剩余 ${slot.available_count}` : '已满'}</Text>
<Text className='slot-count'>
{slot.available_count > 0 ? `剩余 ${slot.available_count}` : '已满'}
</Text>
</View>
))}
</View>
@@ -239,7 +260,12 @@ export default function AppointmentCreate() {
<View className='form-group'>
<Text className='form-label'></Text>
<Input className='form-input' placeholder='请简要描述症状' value={reason} onInput={(e) => setReason(e.detail.value)} />
<Input
className='form-input'
placeholder='请简要描述症状'
value={reason}
onInput={(e) => setReason(e.detail.value)}
/>
</View>
</View>
)}
@@ -256,7 +282,10 @@ export default function AppointmentCreate() {
<Text className='btn-text btn-text-white'></Text>
</View>
) : (
<View className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`} onClick={loading ? undefined : handleSubmit}>
<View
className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`}
onClick={loading ? undefined : handleSubmit}
>
<Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text>
</View>
)}

View File

@@ -5,28 +5,17 @@ import { listConsultations, ConsultationSession } from '@/services/consultation'
import Loading from '../../components/Loading';
import './index.scss';
function getStatusLabel(status: string): string {
const map: Record<string, string> = {
pending: '等待接诊',
active: '进行中',
closed: '已结束',
cancelled: '已取消',
};
return map[status] || status;
}
function getStatusClass(status: string): string {
if (status === 'active') return 'session-status-active';
if (status === 'pending') return 'session-status-pending';
return 'session-status-closed';
function getStatusTag(status: string) {
if (status === 'active') return { label: '进行中', cls: 'tag-ok' };
if (status === 'pending') return { label: '等待接诊', cls: 'tag-warn' };
return { label: { closed: '已结束', cancelled: '已取消' }[status] || status, cls: 'tag-default' };
}
function formatTime(iso: string): string {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffMin = Math.floor((now.getTime() - d.getTime()) / 60000);
if (diffMin < 1) return '刚刚';
if (diffMin < 60) return `${diffMin}分钟前`;
@@ -76,60 +65,65 @@ export default function Consultation() {
return (
<View className='consultation-page'>
{/* 页头 */}
<View className='consultation-header'>
<Text className='consultation-header-title'>线</Text>
<Text className='consultation-header-desc'></Text>
<Text className='consultation-title'>线</Text>
<Text className='consultation-subtitle'></Text>
</View>
{/* 内容区 */}
{loading ? (
<View className='consultation-loading'>
<View className='consultation-center'>
<Loading text='加载中...' />
</View>
) : error ? (
<View className='consultation-error'>
<Text className='consultation-error-text'>{error}</Text>
<View className='consultation-center'>
<Text className='consultation-error'>{error}</Text>
</View>
) : sessions.length === 0 ? (
<View className='consultation-empty'>
<Text className='consultation-empty-icon'>💬</Text>
<Text className='consultation-empty-text'></Text>
<Text className='consultation-empty-hint'></Text>
<View className='empty-icon'>
<Text className='empty-char'></Text>
</View>
<Text className='empty-title'></Text>
<Text className='empty-hint'></Text>
</View>
) : (
<View className='consultation-list'>
{sessions.map((session) => (
<View
key={session.id}
className='consultation-session'
onClick={() => handleTapSession(session)}
>
<View className='session-left'>
<View className='session-top'>
<Text className='session-subject'>
{session.subject || '在线咨询'}
<View className='session-list'>
{sessions.map((session) => {
const tag = getStatusTag(session.status);
return (
<View
key={session.id}
className='session-card'
onClick={() => handleTapSession(session)}
>
<View className='session-main'>
<View className='session-top'>
<Text className='session-subject'>
{session.subject || '在线咨询'}
</Text>
<Text className={`session-tag ${tag.cls}`}>{tag.label}</Text>
</View>
<Text className='session-message'>
{session.last_message || '暂无消息'}
</Text>
<Text className={getStatusClass(session.status)}>
{getStatusLabel(session.status)}
<Text className='session-time'>
{session.last_message_at
? formatTime(session.last_message_at)
: formatTime(session.created_at)}
</Text>
</View>
<Text className='session-message'>
{session.last_message || '暂无消息'}
</Text>
<Text className='session-time'>
{session.last_message_at
? formatTime(session.last_message_at)
: formatTime(session.created_at)}
</Text>
{session.unread_count_patient > 0 && (
<View className='session-badge'>
<Text className='session-badge-text'>
{session.unread_count_patient > 99 ? '99+' : session.unread_count_patient}
</Text>
</View>
)}
</View>
{session.unread_count > 0 && (
<View className='session-badge'>
<Text className='session-badge-text'>
{session.unread_count > 99 ? '99+' : session.unread_count}
</Text>
</View>
)}
</View>
))}
);
})}
</View>
)}
</View>

View File

@@ -118,24 +118,21 @@ export default function PatientDetail() {
)}
{/* 近期化验 */}
{summary?.recent_lab_reports && summary.recent_lab_reports.length > 0 && (
{summary?.latest_lab_report && (
<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
className='lab-item'
onClick={() => Taro.navigateTo({ url: `/pages/doctor/report/detail/index?patientId=${patientId}&id=${summary.latest_lab_report!.id}` })}
>
<View className='lab-item__header'>
<Text className='lab-item__type'>{summary.latest_lab_report.report_type}</Text>
<Text className='lab-item__date'>{summary.latest_lab_report.report_date}</Text>
</View>
))}
{(summary.latest_lab_report.abnormal_count ?? 0) > 0 && (
<Text className='lab-item__abnormal'>{summary.latest_lab_report.abnormal_count} </Text>
)}
</View>
</View>
)}

View File

@@ -101,7 +101,7 @@ export default function FollowUpDetail() {
return (
<View className='detail-page'>
<View className='detail-card'>
<Text className='detail-title'>{task.task_type}</Text>
<Text className='detail-title'>{task.follow_up_type}</Text>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className={`detail-value ${getStatusClass(task.status)}`}>
@@ -110,19 +110,19 @@ export default function FollowUpDetail() {
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{task.due_date}</Text>
<Text className='detail-value'>{task.planned_date}</Text>
</View>
{(() => {
const cd = getCountdown(task.due_date, task.status);
const cd = getCountdown(task.planned_date, task.status);
return cd ? (
<View className={`countdown ${cd.urgent ? 'countdown-urgent' : ''}`}>
<Text className='countdown-text'>{cd.text}</Text>
</View>
) : null;
})()}
{task.description && (
{task.content_template && (
<View className='detail-desc'>
<Text className='detail-desc-text'>{task.description}</Text>
<Text className='detail-desc-text'>{task.content_template}</Text>
</View>
)}
</View>

View File

@@ -15,6 +15,8 @@ interface UpcomingItem {
title: string;
subtitle: string;
type: 'appointment' | 'followup';
statusLabel: string;
statusType: 'ok' | 'warn' | 'default';
}
export default function Index() {
@@ -45,9 +47,11 @@ export default function Index() {
if (a.status === 'pending' || a.status === 'confirmed') {
items.push({
id: a.id,
title: `预约: ${a.appointment_date} ${a.start_time}`,
subtitle: `${a.doctor_name || '医护'} · ${a.status === 'pending' ? '待确认' : '已确认'}`,
title: `${a.appointment_date} ${a.start_time}`,
subtitle: `${a.doctor_name || '医护'} · ${a.department || ''}`,
type: 'appointment',
statusLabel: a.status === 'pending' ? '待确认' : '已确认',
statusType: a.status === 'pending' ? 'warn' : 'ok',
});
}
}
@@ -56,9 +60,11 @@ export default function Index() {
for (const t of taskRes.value.data.slice(0, 2)) {
items.push({
id: t.id,
title: `随访: ${t.task_type}`,
subtitle: `${t.description?.slice(0, 30) || ''} · 截止 ${t.due_date}`,
title: t.follow_up_type,
subtitle: `${t.content_template?.slice(0, 30) || ''} · 截止 ${t.planned_date}`,
type: 'followup',
statusLabel: '进行中',
statusType: 'default',
});
}
}
@@ -71,26 +77,19 @@ export default function Index() {
};
const hour = new Date().getHours();
const greeting = hour < 12 ? '上好' : hour < 18 ? '下午好' : '晚上好';
const greeting = hour < 12 ? '上好' : hour < 18 ? '下午好' : '晚上好';
const displayName = user?.display_name || currentPatient?.name || '访客';
const quickServices = [
{ label: '预约挂号', icon: '📅', path: '/pages/appointment/create/index' },
{ label: '健康录入', icon: '📊', path: '/pages/health/input/index' },
{ label: '健康趋势', icon: '📈', path: '/pages/health/trend/index' },
{ label: '资讯文章', icon: '📰', path: '/pages/article/index' },
{ label: 'AI 报告', icon: '🤖', path: '/pages/ai-report/list/index' },
{ label: '预约挂号', path: '/pages/appointment/create/index' },
{ label: '健康录入', path: '/pages/health/input/index' },
{ label: '健康趋势', path: '/pages/health/trend/index' },
{ label: '资讯文章', path: '/pages/article/index' },
{ label: 'AI 报告', path: '/pages/ai-report/list/index' },
];
const handleServiceClick = (path: string) => {
// tabBar 页面必须使用 switchTab其他页面用 navigateTo
const isTabBar = ['pages/index/index', 'pages/health/index', 'pages/appointment/index', 'pages/article/index', 'pages/profile/index']
.some((p) => path.includes(p));
if (isTabBar) {
Taro.switchTab({ url: path });
} else {
Taro.navigateTo({ url: path });
}
Taro.navigateTo({ url: path });
};
const healthItems = [
@@ -100,61 +99,72 @@ export default function Index() {
{ label: '体重', value: todaySummary?.weight ? `${todaySummary.weight.value}` : '--', unit: 'kg', status: todaySummary?.weight?.status },
];
const getStatusLabel = (status?: string) => {
if (status === 'high') return '偏高 ▲';
if (status === 'low') return '偏低 ▼';
if (status === 'normal') return '正常';
return '';
const getStatusTag = (status?: string) => {
if (status === 'high' || status === 'low') return { label: status === 'high' ? '偏高' : '偏低', cls: 'tag-warn' };
if (status === 'normal') return { label: '正常', cls: 'tag-ok' };
return null;
};
return (
<View className='index-page'>
<View className='greeting-bar'>
<View className='greeting-text'>
<Text className='greeting-hello'>{greeting}</Text>
<View className='home-page'>
{/* 问候区 */}
<View className='greeting-section'>
<View className='greeting-left'>
<Text className='greeting-time'>{greeting}</Text>
<Text className='greeting-name'>{displayName}</Text>
</View>
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN')}</Text>
<Text className='greeting-date'>{new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' })}</Text>
</View>
<View className='health-card'>
{/* 今日健康 */}
<View className='health-section'>
<Text className='section-title'></Text>
{loading && !todaySummary ? (
<Loading />
) : (
<View className='health-grid'>
{healthItems.map((item) => (
<View className={`health-item ${item.status === 'high' || item.status === 'low' ? 'health-item-warn' : item.status === 'normal' ? 'health-item-ok' : ''}`} key={item.label}>
<Text className='health-label'>{item.label}</Text>
<Text className='health-value'>{item.value}</Text>
<View className='health-item-bottom'>
<Text className='health-unit'>{item.unit}</Text>
{item.status && <Text className={`health-status ${item.status}`}>{getStatusLabel(item.status)}</Text>}
{healthItems.map((item) => {
const tag = getStatusTag(item.status);
return (
<View className='health-cell' key={item.label} onClick={() => Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${item.label === '血压' ? 'blood_pressure_systolic' : item.label === '心率' ? 'heart_rate' : item.label === '血糖' ? 'blood_sugar_fasting' : 'weight'}` })}>
<Text className='health-cell-label'>{item.label}</Text>
<Text className='health-cell-value'>{item.value}</Text>
<View className='health-cell-bottom'>
<Text className='health-cell-unit'>{item.unit}</Text>
{tag && <Text className={`health-cell-tag ${tag.cls}`}>{tag.label}</Text>}
</View>
</View>
</View>
))}
);
})}
</View>
)}
</View>
<View className='quick-services'>
{/* 快捷服务 */}
<View className='services-section'>
<Text className='section-title'></Text>
<View className='service-grid'>
<View className='services-row'>
{quickServices.map((svc) => (
<View className='service-item' key={svc.label} onClick={() => handleServiceClick(svc.path)}>
<Text className='service-icon'>{svc.icon}</Text>
<View className='service-btn' key={svc.label} onClick={() => handleServiceClick(svc.path)}>
<View className='service-icon-wrap'>
<Text className='service-icon-text'>{svc.label[0]}</Text>
</View>
<Text className='service-label'>{svc.label}</Text>
</View>
))}
</View>
</View>
<View className='upcoming'>
{/* 待办事项 */}
<View className='upcoming-section'>
<Text className='section-title'></Text>
{upcomingLoading ? (
<Loading />
) : upcomingItems.length === 0 ? (
<EmptyState icon='📋' text='暂无待办事项' hint='预约挂号后将在此显示' />
<View className='upcoming-empty'>
<Text className='upcoming-empty-text'></Text>
<Text className='upcoming-empty-hint'></Text>
</View>
) : (
<View className='upcoming-list'>
{upcomingItems.map((item) => (
@@ -163,7 +173,7 @@ export default function Index() {
className='upcoming-item'
onClick={() => {
if (item.type === 'appointment') {
Taro.navigateTo({ url: `/pages/appointment/index` });
Taro.navigateTo({ url: '/pages/appointment/index' });
} else {
Taro.navigateTo({ url: `/pages/followup/detail/index?id=${item.id}` });
}
@@ -173,6 +183,7 @@ export default function Index() {
<Text className='upcoming-item-title'>{item.title}</Text>
<Text className='upcoming-item-sub'>{item.subtitle}</Text>
</View>
<Text className={`upcoming-item-tag tag-${item.statusType}`}>{item.statusLabel}</Text>
<Text className='upcoming-item-arrow'></Text>
</View>
))}

View File

@@ -64,6 +64,7 @@ export default function MyFollowUps() {
onClick={() => handleTabChange(tab.key)}
>
<Text className='tab-text'>{tab.label}</Text>
{activeTab === tab.key && <View className='tab-indicator' />}
</View>
))}
</View>
@@ -76,13 +77,13 @@ export default function MyFollowUps() {
onClick={() => goToDetail(t.id)}
>
<View className='task-top'>
<Text className='task-name'>{t.task_type}</Text>
<Text className='task-name'>{t.follow_up_type}</Text>
<Text className={`task-status ${getStatusClass(t.status)}`}>
{getStatusLabel(t.status)}
</Text>
</View>
<Text className='task-desc'>{t.description}</Text>
<Text className='task-due'>{t.due_date}</Text>
<Text className='task-desc'>{t.content_template}</Text>
<Text className='task-due'>: {t.planned_date}</Text>
</View>
))}
</View>