Files
hms/apps/miniprogram/src/pages/appointment/create/index.tsx
iven d623f8b2ff fix: V1 测试版本端到端验证修复 — 6 CRITICAL + 3 HIGH 问题全量修复
修复项:
- fix(db): 迁移 149 — 修复 Admin 角色权限绑定被迁移链破坏 (FE-C1)
- fix(health): 4 个 handler 添加空名称验证 — Doctor/Article/AlertRule/Tag (API-C1~C4)
- fix(health): Stats 仪表盘 new_this_week 查询修复 — SeaORM date_trunc bug (FE-C2)
- fix(server): 添加安全响应头 — X-Frame-Options/CSP/XSS-Protection/Referrer-Policy (SEC-H1)
- fix(mp): 预约创建契约修复 — notes/reason 字段映射 + 移除 schedule_id (MP-H1)
- fix(mp): 咨询会话 subject/last_message 字段改为可选 (MP-H3)
- fix(ai): AiConfig Default derive 替代手写 impl (clippy)

测试报告:
- 8 维度端到端测试全部完成 (后端 87 用例 / 前端 30 页面 / 小程序 80+ API / 安全 20 项 / 性能 20 端点)
- 多角色 7 角色 49 检查 100% 通过
- 综合测试报告 + 专家评估报告
2026-05-18 10:24:40 +08:00

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useMemo } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { listDoctors, createAppointment, calendarView } from '../../../services/appointment';
import { useAuthStore } from '../../../stores/auth';
import { TEMPLATE_IDS } from '@/services/wechat-templates';
import { trackEvent } from '@/services/analytics';
import StepIndicator from '../../../components/StepIndicator';
import WeekCalendar from '../../../components/WeekCalendar';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
import { useElderClass } from '../../../hooks/useElderClass';
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
import './index.scss';
const DEPARTMENTS = [
{ label: '内科', initial: '内' },
{ label: '外科', initial: '外' },
{ label: '妇科', initial: '妇' },
{ label: '儿科', initial: '儿' },
{ label: '体检中心', initial: '检' },
{ label: '中医科', initial: '中' },
];
interface DoctorItem {
id: string;
name: string;
title?: string;
department?: string;
specialty?: string;
}
interface TimeSlot {
start_time: string;
end_time: string;
label: string;
available_count: number;
}
export default function AppointmentCreate() {
const [currentStep, setCurrentStep] = useState(0);
const [department, setDepartment] = useState('');
const [doctors, setDoctors] = useState<DoctorItem[]>([]);
const [selectedDoctor, setSelectedDoctor] = useState<DoctorItem | null>(null);
const [appointmentDate, setAppointmentDate] = useState('');
const [timeSlot, setTimeSlot] = useState('');
const [reason, setReason] = useState('');
const [loading, setLoading] = useState(false);
const { safeSetTimeout } = useSafeTimeout();
const [schedules, setSchedules] = useState<any[]>([]);
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
const modeClass = useElderClass();
const currentPatient = useAuthStore((s) => s.currentPatient);
const scheduledDates = useMemo(() => {
if (!schedules) return new Set<string>();
return new Set(schedules.map((s: any) => s.date || s.appointment_date));
}, [schedules]);
const onSelectDept = useCallback(async (dept: string) => {
setDepartment(dept);
setSelectedDoctor(null);
try {
const res = await listDoctors(dept);
setDoctors(res.data || []);
} catch {
Taro.showToast({ title: '加载医生失败', icon: 'none' });
}
}, []);
const onSelectDoctor = useCallback(async (doctor: DoctorItem) => {
setSelectedDoctor(doctor);
setLoading(true);
try {
const today = new Date();
const endDate = new Date(today);
endDate.setDate(today.getDate() + 30);
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const res = await calendarView(fmt(today), fmt(endDate), doctor.id);
setSchedules(res || []);
} catch {
// 日历加载失败不阻塞
} finally {
setLoading(false);
}
}, []);
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_appointments - (s.current_appointments || 0)),
}));
setTimeSlots(daySlots);
}, [schedules]);
const getSlotStyle = (available: number) => {
if (available === 0) return 'slot-full';
if (available <= 3) return 'slot-few';
return 'slot-available';
};
const handleSubmit = useCallback(async () => {
if (!selectedDoctor) { Taro.showToast({ title: '请选择医生', icon: 'none' }); return; }
if (!appointmentDate) { Taro.showToast({ title: '请选择日期', icon: 'none' }); return; }
if (!timeSlot) { Taro.showToast({ title: '请选择时段', icon: 'none' }); return; }
if (!currentPatient) { Taro.showToast({ title: '请先选择就诊人', icon: 'none' }); return; }
setLoading(true);
try {
const selectedSlot = timeSlots.find((s) => s.label === timeSlot);
await createAppointment({
patient_id: currentPatient.id,
doctor_id: selectedDoctor.id,
appointment_date: appointmentDate,
start_time: selectedSlot?.start_time || timeSlot,
end_time: selectedSlot?.end_time || timeSlot,
notes: reason.trim() || undefined,
});
Taro.showToast({ title: '预约成功', icon: 'success' });
trackEvent('appointment_create', { doctor_id: selectedDoctor.id, date: appointmentDate });
const tmplId = TEMPLATE_IDS.APPOINTMENT_REMINDER;
if (tmplId) {
try {
await (Taro.requestSubscribeMessage as any)({ tmplIds: [tmplId] });
} catch { /* 用户拒绝 */ }
}
safeSetTimeout(() => Taro.navigateBack(), 1500);
} catch (err: any) {
const msg = err?.message || '预约失败';
Taro.showToast({ title: msg.length > 20 ? msg.slice(0, 20) : msg, icon: 'none' });
} finally {
setLoading(false);
}
}, [selectedDoctor, appointmentDate, timeSlot, reason, currentPatient]);
const goNext = () => {
if (currentStep === 0 && !department) { Taro.showToast({ title: '请先选择科室', icon: 'none' }); return; }
if (currentStep === 1 && !selectedDoctor) { Taro.showToast({ title: '请选择医生', icon: 'none' }); return; }
setCurrentStep(Math.min(currentStep + 1, 2));
};
const handleStepChange = (idx: number) => {
if (idx < currentStep) {
if (idx <= 1) setSelectedDoctor(null);
if (idx <= 2) { setAppointmentDate(''); setTimeSlot(''); }
setCurrentStep(idx);
}
};
return (
<PageShell padding="none" safeBottom={false} scroll={false} className={modeClass}>
<StepIndicator
steps={[{ label: '选科室' }, { label: '选医生' }, { label: '选时段' }]}
current={currentStep}
onChange={handleStepChange}
/>
{/* Step 1: 科室宫格 */}
{currentStep === 0 && (
<View className='step-content'>
<Text className='step-title'></Text>
<View className='dept-grid'>
{DEPARTMENTS.map((dept) => (
<View
className={`dept-card ${department === dept.label ? 'dept-selected' : ''}`}
key={dept.label}
onClick={() => onSelectDept(dept.label)}
>
<View className='dept-initial-circle'>
<Text className='dept-initial-text'>{dept.initial}</Text>
</View>
<Text className='dept-label'>{dept.label}</Text>
</View>
))}
</View>
</View>
)}
{/* Step 2: 医生列表 */}
{currentStep === 1 && (
<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='doctor-list'>
{doctors.map((doc) => (
<View
className={`doctor-card ${selectedDoctor?.id === doc.id ? 'doctor-selected' : ''}`}
key={doc.id}
onClick={() => onSelectDoctor(doc)}
>
<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 && (
<View className='doctor-check'>
<Text className='doctor-check-text'>&#10003;</Text>
</View>
)}
</View>
))}
</View>
)}
</View>
)}
{/* Step 3: 日历 + 时段 */}
{currentStep === 2 && (
<View className='step-content'>
<Text className='step-title'></Text>
<ContentCard 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>
</ContentCard>
<WeekCalendar
scheduledDates={scheduledDates}
selectedDate={appointmentDate}
onSelectDate={onSelectDate}
/>
{appointmentDate && timeSlots.length > 0 && (
<View className='slot-section'>
<Text className='slot-section-title'></Text>
<View className='slot-grid'>
{timeSlots.map((slot) => (
<View
className={`slot-card ${getSlotStyle(slot.available_count)} ${timeSlot === slot.label ? 'slot-selected' : ''}`}
key={slot.label}
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>
</View>
))}
</View>
</View>
)}
<View className='form-group'>
<Text className='form-label'></Text>
<Input
className='form-input'
placeholder='请简要描述症状'
value={reason}
onInput={(e) => setReason(e.detail.value)}
/>
</View>
</View>
)}
{/* 底部操作栏 */}
<View className='bottom-bar'>
{currentStep > 0 && (
<View className='btn btn-prev' onClick={() => handleStepChange(currentStep - 1)}>
<Text className='btn-text'></Text>
</View>
)}
{currentStep < 2 ? (
<View className='btn btn-next' onClick={goNext}>
<Text className='btn-text btn-text-white'></Text>
</View>
) : (
<View
className={`btn btn-submit ${loading ? 'btn-disabled' : ''}`}
onClick={loading ? undefined : handleSubmit}
>
<Text className='btn-text btn-text-white'>{loading ? '提交中...' : '确认预约'}</Text>
</View>
)}
</View>
</PageShell>
);
}