修复项: - 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% 通过 - 综合测试报告 + 专家评估报告
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
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'>✓</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>
|
||
);
|
||
}
|