diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index a27f902..1357e69 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -5,8 +5,21 @@ export default defineAppConfig({ 'pages/health/input/index', 'pages/health/trend/index', 'pages/appointment/index', + 'pages/appointment/create/index', + 'pages/appointment/detail/index', 'pages/article/index', + 'pages/article/detail/index', + 'pages/report/index', + 'pages/report/detail/index', + 'pages/followup/index', + 'pages/followup/detail/index', 'pages/profile/index', + 'pages/profile/family/index', + 'pages/profile/family-add/index', + 'pages/profile/reports/index', + 'pages/profile/followups/index', + 'pages/profile/medication/index', + 'pages/profile/settings/index', 'pages/login/index', ], tabBar: { diff --git a/apps/miniprogram/src/pages/appointment/create/index.scss b/apps/miniprogram/src/pages/appointment/create/index.scss new file mode 100644 index 0000000..10c819a --- /dev/null +++ b/apps/miniprogram/src/pages/appointment/create/index.scss @@ -0,0 +1,289 @@ +@import '../../../styles/variables.scss'; + +.create-page { + min-height: 100vh; + background: $bg; + padding-bottom: 140px; +} + +/* 步骤指示器 */ +.step-bar { + display: flex; + justify-content: space-around; + padding: 32px 24px 0; +} + +.step-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + opacity: 0.4; + transition: opacity 0.3s; +} + +.step-item.step-active { + opacity: 1; +} + +.step-item.step-done { + opacity: 0.8; +} + +.step-dot { + width: 56px; + height: 56px; + border-radius: 50%; + background: $bd; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.3s; +} + +.step-active .step-dot { + background: $pri; +} + +.step-done .step-dot { + background: $acc; +} + +.step-num { + font-size: 28px; + color: white; + font-weight: bold; +} + +.step-check { + font-size: 28px; + color: white; + font-weight: bold; +} + +.step-label { + font-size: 22px; + color: $tx2; +} + +.step-active .step-label { + color: $pri; + font-weight: 500; +} + +/* 步骤连接线 */ +.step-line-wrapper { + padding: 12px 80px 0; +} + +.step-line { + height: 4px; + background: $bd; + border-radius: 2px; + overflow: hidden; +} + +.step-line-fill { + height: 100%; + background: linear-gradient(90deg, $acc, $pri); + border-radius: 2px; + transition: width 0.3s ease; +} + +/* 步骤内容 */ +.step-content { + padding: 32px 24px; +} + +.step-title { + font-size: 32px; + font-weight: bold; + color: $tx; + margin-bottom: 28px; + display: block; +} + +/* 选择器卡片 */ +.picker-card { + background: $card; + border-radius: $r; + padding: 24px 28px; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.picker-value { + font-size: 28px; + color: $tx; +} + +.picker-value.placeholder { + color: $tx3; +} + +.picker-arrow { + font-size: 24px; + color: $tx3; +} + +/* 医生列表 */ +.doctor-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.doctor-card { + background: $card; + border-radius: $r; + padding: 24px 28px; + display: flex; + align-items: center; + gap: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + border: 2px solid transparent; + transition: border-color 0.2s; +} + +.doctor-card.doctor-selected { + border-color: $pri; + background: $pri-surface; +} + +.doctor-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + background: $pri-l; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.doctor-avatar-text { + font-size: 32px; + color: $pri; + font-weight: bold; +} + +.doctor-detail { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.doctor-name { + font-size: 30px; + font-weight: bold; + color: $tx; +} + +.doctor-title { + font-size: 24px; + color: $tx2; +} + +.doctor-specialty { + font-size: 22px; + color: $pri; +} + +.doctor-check { + font-size: 32px; + color: $pri; + font-weight: bold; +} + +/* 表单 */ +.form-group { + margin-bottom: 28px; +} + +.form-label { + font-size: 26px; + color: $tx2; + margin-bottom: 12px; + display: block; +} + +.form-static { + background: $card; + border-radius: $r-sm; + padding: 24px 28px; +} + +.form-static-text { + font-size: 28px; + color: $tx; +} + +.form-input { + background: $card; + border-radius: $r-sm; + padding: 24px 28px; + font-size: 28px; + color: $tx; + width: 100%; + box-sizing: border-box; +} + +/* 空状态 */ +.empty-state { + padding: 80px 0; + text-align: center; +} + +.empty-text { + font-size: 28px; + color: $tx3; +} + +/* 底部操作栏 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + gap: 16px; + padding: 20px 24px; + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); + background: $card; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); +} + +.btn { + flex: 1; + padding: 24px 0; + border-radius: $r-sm; + text-align: center; + transition: opacity 0.2s; +} + +.btn-prev { + background: $bd-l; +} + +.btn-next, +.btn-submit { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); +} + +.btn-disabled { + opacity: 0.5; +} + +.btn-text { + font-size: 30px; + font-weight: bold; + color: $tx2; +} + +.btn-text-white { + color: white; +} diff --git a/apps/miniprogram/src/pages/appointment/create/index.tsx b/apps/miniprogram/src/pages/appointment/create/index.tsx new file mode 100644 index 0000000..a8ddb4e --- /dev/null +++ b/apps/miniprogram/src/pages/appointment/create/index.tsx @@ -0,0 +1,268 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, Picker, Input } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { listDoctors, createAppointment } from '../../../services/appointment'; +import { useAuthStore } from '../../../stores/auth'; +import './index.scss'; + +const DEPARTMENTS = ['内科', '外科', '妇科', '儿科', '体检中心']; + +interface DoctorItem { + id: string; + name: string; + title?: string; + department?: string; + specialty?: string; +} + +export default function AppointmentCreate() { + const [currentStep, setCurrentStep] = useState(0); + const [department, setDepartment] = useState(''); + const [deptPickerIndex, setDeptPickerIndex] = useState(0); + const [doctors, setDoctors] = useState([]); + const [selectedDoctor, setSelectedDoctor] = useState(null); + const [appointmentDate, setAppointmentDate] = useState(''); + const [timeSlot, setTimeSlot] = useState(''); + const [reason, setReason] = useState(''); + const [loading, setLoading] = useState(false); + + const currentPatient = useAuthStore((s) => s.currentPatient); + + // Step 1: 选择科室后加载医生列表 + const onDepartmentChange = useCallback(async (e: any) => { + const idx = e.detail.value; + const dept = DEPARTMENTS[idx]; + setDeptPickerIndex(idx); + setDepartment(dept); + setSelectedDoctor(null); + + try { + const res = await listDoctors(dept); + setDoctors(res.data || []); + } catch { + Taro.showToast({ title: '加载医生失败', icon: 'none' }); + } + }, []); + + // Step 2: 选择医生 + const onSelectDoctor = useCallback((doctor: DoctorItem) => { + setSelectedDoctor(doctor); + }, []); + + // Step 3: 日期变更 + const onDateChange = useCallback((e: any) => { + setAppointmentDate(e.detail.value); + }, []); + + // Step 3: 时段变更 + const onTimeSlotChange = useCallback((e: any) => { + setTimeSlot(e.detail.value); + }, []); + + // Step 3: 备注变更 + const onReasonChange = useCallback((e: any) => { + setReason(e.detail.value); + }, []); + + // 提交预约 + const handleSubmit = useCallback(async () => { + if (!selectedDoctor) { + Taro.showToast({ title: '请选择医生', icon: 'none' }); + return; + } + if (!appointmentDate) { + Taro.showToast({ title: '请选择日期', icon: 'none' }); + return; + } + if (!timeSlot.trim()) { + Taro.showToast({ title: '请输入时段', icon: 'none' }); + return; + } + if (!currentPatient) { + Taro.showToast({ title: '请先选择就诊人', icon: 'none' }); + return; + } + + setLoading(true); + try { + await createAppointment({ + patient_id: currentPatient.id, + doctor_id: selectedDoctor.id, + schedule_id: '', + appointment_date: appointmentDate, + time_slot: timeSlot.trim(), + reason: reason.trim() || undefined, + }); + Taro.showToast({ title: '预约成功', icon: 'success' }); + setTimeout(() => { + Taro.navigateBack(); + }, 1500); + } catch { + Taro.showToast({ title: '预约失败', 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 goPrev = () => { + setCurrentStep(Math.max(currentStep - 1, 0)); + }; + + const stepLabels = ['选择科室', '选择医生', '选择日期与时段']; + + return ( + + {/* 步骤指示器 */} + + {stepLabels.map((label, idx) => ( + + + {idx < currentStep ? ( + + ) : ( + {idx + 1} + )} + + {label} + + ))} + + + {/* 步骤连接线 */} + + + + + + + {/* Step 1: 选择科室 */} + {currentStep === 0 && ( + + 请选择就诊科室 + + + + {department || '点击选择科室'} + + + + + + )} + + {/* Step 2: 选择医生 */} + {currentStep === 1 && ( + + {department} - 请选择医生 + {doctors.length === 0 ? ( + + 暂无可选医生 + + ) : ( + + {doctors.map((doc) => ( + onSelectDoctor(doc)} + > + + {doc.name.charAt(0)} + + + {doc.name} + {doc.title || '医生'} + {doc.specialty && {doc.specialty}} + + {selectedDoctor?.id === doc.id && ( + + )} + + ))} + + )} + + )} + + {/* Step 3: 选择日期和时段 */} + {currentStep === 2 && ( + + 选择就诊时间 + + + 医生 + + {selectedDoctor?.name} - {department} + + + + + 就诊日期 + + + + {appointmentDate || '点击选择日期'} + + + + + + + + 就诊时段 + + + + + 备注(选填) + + + + )} + + {/* 底部操作栏 */} + + {currentStep > 0 && ( + + 上一步 + + )} + {currentStep < 2 ? ( + + 下一步 + + ) : ( + + {loading ? '提交中...' : '确认预约'} + + )} + + + ); +} diff --git a/apps/miniprogram/src/pages/appointment/detail/index.scss b/apps/miniprogram/src/pages/appointment/detail/index.scss new file mode 100644 index 0000000..08e9597 --- /dev/null +++ b/apps/miniprogram/src/pages/appointment/detail/index.scss @@ -0,0 +1,216 @@ +@import '../../../styles/variables.scss'; + +.detail-page { + min-height: 100vh; + background: $bg; + padding-bottom: 140px; +} + +/* 顶部导航 */ +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 32px; + padding-top: 48px; + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); +} + +.back-btn { + padding: 8px 0; +} + +.back-text { + font-size: 28px; + color: white; +} + +.header-title { + font-size: 34px; + font-weight: bold; + color: white; +} + +.header-placeholder { + width: 60px; +} + +/* 状态卡片 */ +.status-card { + background: $card; + border-radius: $r-lg; + padding: 40px 32px; + margin: -20px 24px 24px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.status-badge { + padding: 8px 32px; + border-radius: 24px; + margin-bottom: 12px; + + .status-badge-text { + font-size: 28px; + font-weight: bold; + } + + &.tag-pending { + background: $wrn-l; + .status-badge-text { color: $wrn; } + } + + &.tag-confirmed { + background: $acc-l; + .status-badge-text { color: $acc; } + } + + &.tag-cancelled { + background: $bd-l; + .status-badge-text { color: $tx3; } + } + + &.tag-completed { + background: $pri-l; + .status-badge-text { color: $pri; } + } +} + +.status-doctor { + font-size: 36px; + font-weight: bold; + color: $tx; +} + +.status-dept { + font-size: 26px; + color: $tx2; +} + +/* 详情信息 */ +.info-section { + margin: 0 24px 24px; + background: $card; + border-radius: $r; + padding: 28px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.section-title { + font-size: 30px; + font-weight: bold; + color: $tx; + margin-bottom: 24px; + display: block; +} + +.info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 0; + border-bottom: 1px solid $bd-l; +} + +.info-item:last-child { + border-bottom: none; +} + +.info-label { + font-size: 26px; + color: $tx2; +} + +.info-value { + font-size: 26px; + color: $tx; + font-weight: 500; +} + +.info-id { + font-size: 22px; + color: $tx3; + word-break: break-all; + max-width: 400px; + text-align: right; +} + +/* 温馨提示 */ +.tips-card { + margin: 0 24px 24px; + background: $wrn-l; + border-radius: $r; + padding: 24px 28px; +} + +.tips-title { + font-size: 26px; + font-weight: bold; + color: $wrn; + margin-bottom: 12px; + display: block; +} + +.tips-text { + font-size: 24px; + color: $tx2; + line-height: 1.6; +} + +/* 底部操作 */ +.bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + padding: 20px 24px; + padding-bottom: constant(safe-area-inset-bottom); + padding-bottom: env(safe-area-inset-bottom); + background: $card; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); +} + +.cancel-btn { + background: $dan-l; + border-radius: $r-sm; + padding: 24px 0; + text-align: center; + transition: opacity 0.2s; +} + +.cancel-disabled { + opacity: 0.5; +} + +.cancel-text { + font-size: 30px; + font-weight: bold; + color: $dan; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + padding: 120px 0; +} + +.empty-icon { + font-size: 80px; + margin-bottom: 20px; +} + +.empty-text { + font-size: 30px; + color: $tx2; + margin-bottom: 12px; +} + +.empty-hint { + font-size: 24px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/appointment/detail/index.tsx b/apps/miniprogram/src/pages/appointment/detail/index.tsx new file mode 100644 index 0000000..3ce7780 --- /dev/null +++ b/apps/miniprogram/src/pages/appointment/detail/index.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import { cancelAppointment } from '../../../services/appointment'; +import type { Appointment } from '../../../services/appointment'; +import './index.scss'; + +const STATUS_MAP: Record = { + pending: { label: '待确认', className: 'tag-pending' }, + confirmed: { label: '已确认', className: 'tag-confirmed' }, + cancelled: { label: '已取消', className: 'tag-cancelled' }, + completed: { label: '已完成', className: 'tag-completed' }, +}; + +export default function AppointmentDetail() { + const router = useRouter(); + const id = router.params.id || ''; + const [cancelling, setCancelling] = useState(false); + + // 从页面参数或全局缓存获取预约数据 + const encodedData = router.params.data || ''; + let appointment: Appointment | null = null; + try { + if (encodedData) { + appointment = JSON.parse(decodeURIComponent(encodedData)); + } + } catch { + // 解析失败则尝试从 Storage 获取 + const cached = Taro.getStorageSync('appointment_detail_cache'); + if (cached && cached.id === id) { + appointment = cached; + } + } + + // 如果没有传数据,尝试从缓存获取 + if (!appointment) { + const cached = Taro.getStorageSync('appointment_detail_cache'); + if (cached && cached.id === id) { + appointment = cached; + } + } + + const status = appointment ? (STATUS_MAP[appointment.status] || { label: appointment.status, className: 'tag-pending' }) : { label: '未知', className: 'tag-pending' }; + const canCancel = appointment && (appointment.status === 'pending' || appointment.status === 'confirmed'); + + const handleCancel = async () => { + if (!appointment || cancelling) return; + + const res = await Taro.showModal({ + title: '确认取消', + content: '确定要取消此预约吗?取消后需重新预约。', + }); + if (!res.confirm) return; + + setCancelling(true); + try { + await cancelAppointment(appointment.id, appointment.version); + Taro.showToast({ title: '已取消预约', icon: 'success' }); + setTimeout(() => { + Taro.navigateBack(); + }, 1500); + } catch { + Taro.showToast({ title: '取消失败', icon: 'none' }); + } finally { + setCancelling(false); + } + }; + + const goBack = () => { + Taro.navigateBack(); + }; + + if (!appointment) { + return ( + + + + 返回 + + 预约详情 + + + + 📋 + 未找到预约信息 + 请从预约列表进入 + + + ); + } + + return ( + + {/* 顶部导航 */} + + + 返回 + + 预约详情 + + + + {/* 状态卡片 */} + + + {status.label} + + {appointment.doctor_name} + {appointment.department} + + + {/* 详情信息 */} + + 预约信息 + + + 就诊人 + {appointment.patient_name} + + + + 就诊日期 + {appointment.appointment_date} + + + + 就诊时段 + {appointment.time_slot} + + + + 预约单号 + {appointment.id} + + + + {/* 温馨提示 */} + {(appointment.status === 'pending' || appointment.status === 'confirmed') && ( + + 温馨提示 + 请按预约时间提前15分钟到达,携带有效身份证件和医保卡。 + + )} + + {/* 底部操作 */} + {canCancel && ( + + + {cancelling ? '处理中...' : '取消预约'} + + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/appointment/index.scss b/apps/miniprogram/src/pages/appointment/index.scss index da97dd7..073b894 100644 --- a/apps/miniprogram/src/pages/appointment/index.scss +++ b/apps/miniprogram/src/pages/appointment/index.scss @@ -1 +1,176 @@ @import '../../styles/variables.scss'; + +.appointment-page { + min-height: 100vh; + background: $bg; + padding-bottom: 140px; +} + +.page-header { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + padding: 32px; + padding-top: 48px; +} + +.page-title { + font-size: 36px; + font-weight: bold; + color: white; +} + +.appointment-list { + padding: 0 24px; + margin-top: -16px; +} + +.appointment-card { + background: $card; + border-radius: $r; + padding: 28px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.card-top { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.doctor-info { + display: flex; + align-items: center; + gap: 12px; +} + +.doctor-name { + font-size: 32px; + font-weight: bold; + color: $tx; +} + +.department { + font-size: 24px; + color: $pri; + background: $pri-l; + padding: 4px 16px; + border-radius: 20px; +} + +.status-tag { + padding: 6px 20px; + border-radius: 20px; + + .status-tag-text { + font-size: 22px; + font-weight: 500; + } + + &.tag-pending { + background: $wrn-l; + + .status-tag-text { + color: $wrn; + } + } + + &.tag-confirmed { + background: $acc-l; + + .status-tag-text { + color: $acc; + } + } + + &.tag-cancelled { + background: $bd-l; + + .status-tag-text { + color: $tx3; + } + } + + &.tag-completed { + background: $pri-l; + + .status-tag-text { + color: $pri; + } + } +} + +.card-bottom { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 20px; + border-top: 1px solid $bd-l; +} + +.info-row { + display: flex; + align-items: center; + gap: 8px; +} + +.info-icon { + font-size: 26px; +} + +.info-text { + font-size: 26px; + color: $tx2; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 120px 0 80px; +} + +.empty-icon { + font-size: 80px; + margin-bottom: 20px; +} + +.empty-text { + font-size: 30px; + color: $tx2; + margin-bottom: 12px; +} + +.empty-hint { + font-size: 24px; + color: $tx3; +} + +.loading-tip { + text-align: center; + padding: 24px 0; +} + +.loading-text { + font-size: 24px; + color: $tx3; +} + +.fab-btn { + position: fixed; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + padding: 24px 64px; + border-radius: 48px; + box-shadow: 0 8px 24px rgba($pri, 0.4); + z-index: 100; +} + +.fab-text { + font-size: 30px; + color: white; + font-weight: bold; +} diff --git a/apps/miniprogram/src/pages/appointment/index.tsx b/apps/miniprogram/src/pages/appointment/index.tsx index 124f9f7..efdc390 100644 --- a/apps/miniprogram/src/pages/appointment/index.tsx +++ b/apps/miniprogram/src/pages/appointment/index.tsx @@ -1,12 +1,141 @@ +import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import '../health/index.scss'; +import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { listAppointments, cancelAppointment } from '../../services/appointment'; +import type { Appointment } from '../../services/appointment'; +import './index.scss'; + +const STATUS_MAP: Record = { + pending: { label: '待确认', className: 'tag-pending' }, + confirmed: { label: '已确认', className: 'tag-confirmed' }, + cancelled: { label: '已取消', className: 'tag-cancelled' }, + completed: { label: '已完成', className: 'tag-completed' }, +}; + +export default function AppointmentList() { + const [appointments, setAppointments] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const fetchData = useCallback(async (pageNum: number, isRefresh = false) => { + if (loading) return; + setLoading(true); + try { + const res = await listAppointments(pageNum); + const list = res.data || []; + if (isRefresh) { + setAppointments(list); + } else { + setAppointments((prev) => [...prev, ...list]); + } + setTotal(res.total); + setPage(pageNum); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [loading]); + + useDidShow(() => { + fetchData(1, true); + }); + + usePullDownRefresh(() => { + setRefreshing(true); + fetchData(1, true).finally(() => { + Taro.stopPullDownRefresh(); + }); + }); + + useReachBottom(() => { + if (!loading && appointments.length < total) { + fetchData(page + 1); + } + }); + + const goCreate = () => { + Taro.navigateTo({ url: '/pages/appointment/create/index' }); + }; + + const goDetail = (id: string) => { + const item = appointments.find((a) => a.id === id); + if (item) { + Taro.setStorageSync('appointment_detail_cache', item); + } + Taro.navigateTo({ url: `/pages/appointment/detail/index?id=${id}` }); + }; + + const getStatusTag = (status: string) => { + return STATUS_MAP[status] || { label: status, className: 'tag-pending' }; + }; -export default function Appointment() { return ( - - 📅 - 预约挂号 - 选择科室、医生、时段 + + {/* 页面标题 */} + + 预约挂号 + + + {/* 预约列表 */} + {appointments.length === 0 && !loading ? ( + + 📋 + 暂无预约记录 + 点击下方按钮新建预约 + + ) : ( + + {appointments.map((item) => { + const tag = getStatusTag(item.status); + return ( + goDetail(item.id)} + > + + + {item.doctor_name} + {item.department} + + + {tag.label} + + + + + 📅 + {item.appointment_date} + + + 🕐 + {item.time_slot} + + + + ); + })} + {loading && ( + + 加载中... + + )} + {!loading && appointments.length >= total && total > 0 && ( + + 没有更多了 + + )} + + )} + + {/* 底部悬浮按钮 */} + + + 新建预约 + ); } diff --git a/apps/miniprogram/src/pages/article/detail/index.scss b/apps/miniprogram/src/pages/article/detail/index.scss new file mode 100644 index 0000000..aa72b6a --- /dev/null +++ b/apps/miniprogram/src/pages/article/detail/index.scss @@ -0,0 +1,98 @@ +@import '../../../styles/variables.scss'; + +.article-detail-page { + min-height: 100vh; + background: $bg; + padding-bottom: 40px; +} + +.article-header { + background: $card; + padding: 32px; + margin-bottom: 2px; +} + +.article-title { + font-size: 38px; + font-weight: bold; + color: $tx; + display: block; + line-height: 1.4; + margin-bottom: 16px; +} + +.article-meta { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.article-category { + font-size: 22px; + color: $pri; + background: $pri-l; + padding: 4px 12px; + border-radius: 12px; +} + +.article-author { + font-size: 24px; + color: $tx2; +} + +.article-date { + font-size: 24px; + color: $tx3; +} + +.article-summary { + background: $card; + padding: 24px 32px; + margin-bottom: 2px; +} + +.summary-text { + font-size: 26px; + color: $tx2; + line-height: 1.6; +} + +.article-content { + background: $card; + padding: 32px; + + // RichText 内部样式优化 + h1, h2, h3 { + font-weight: bold; + color: #134E4A; + margin: 24px 0 12px; + } + + p { + font-size: 28px; + color: #134E4A; + line-height: 1.8; + margin-bottom: 16px; + } + + img { + max-width: 100%; + border-radius: 8px; + margin: 12px 0; + } +} + +.loading-state, +.empty-state { + display: flex; + justify-content: center; + align-items: center; + padding: 120px 0; +} + +.loading-text, +.empty-text { + font-size: 28px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/article/detail/index.tsx b/apps/miniprogram/src/pages/article/detail/index.tsx new file mode 100644 index 0000000..688f6ca --- /dev/null +++ b/apps/miniprogram/src/pages/article/detail/index.tsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, RichText } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import { getArticleDetail, Article } from '../../../services/article'; +import './index.scss'; + +export default function ArticleDetail() { + const router = useRouter(); + const id = router.params.id || ''; + + const [article, setArticle] = useState
(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + setLoading(true); + getArticleDetail(id) + .then((data) => setArticle(data)) + .catch(() => Taro.showToast({ title: '加载失败', icon: 'none' })) + .finally(() => setLoading(false)); + }, [id]); + + if (loading) { + return ( + + + 加载中... + + + ); + } + + if (!article) { + return ( + + + 文章不存在 + + + ); + } + + return ( + + {/* 文章头部 */} + + {article.title} + + {article.category && ( + {article.category} + )} + {article.author && ( + {article.author} + )} + {article.published_at && ( + {article.published_at.slice(0, 10)} + )} + + + + {/* 摘要 */} + {article.summary && ( + + {article.summary} + + )} + + {/* 正文 */} + + + + + ); +} diff --git a/apps/miniprogram/src/pages/article/index.scss b/apps/miniprogram/src/pages/article/index.scss index da97dd7..ced095e 100644 --- a/apps/miniprogram/src/pages/article/index.scss +++ b/apps/miniprogram/src/pages/article/index.scss @@ -1 +1,108 @@ @import '../../styles/variables.scss'; + +.article-page { + min-height: 100vh; + background: $bg; + padding: 24px; + padding-bottom: 40px; +} + +.article-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.article-card { + display: flex; + background: $card; + border-radius: $r; + padding: 28px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.article-card-body { + flex: 1; + display: flex; + flex-direction: column; + margin-right: 20px; +} + +.article-card-title { + font-size: 30px; + font-weight: bold; + color: $tx; + line-height: 1.4; + display: block; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.article-card-summary { + font-size: 26px; + color: $tx2; + line-height: 1.4; + display: block; + margin-bottom: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.article-card-meta { + display: flex; + align-items: center; + gap: 12px; +} + +.article-card-tag { + font-size: 22px; + color: $pri; + background: $pri-l; + padding: 2px 12px; + border-radius: 12px; +} + +.article-card-date { + font-size: 22px; + color: $tx3; +} + +.article-card-cover { + width: 180px; + height: 120px; + border-radius: $r-sm; + overflow: hidden; + flex-shrink: 0; +} + +.cover-img { + width: 100%; + height: 100%; +} + +.empty-state { + display: flex; + justify-content: center; + align-items: center; + padding: 120px 0; +} + +.empty-text { + font-size: 28px; + color: $tx3; +} + +.loading-hint { + text-align: center; + padding: 24px 0; +} + +.loading-text { + font-size: 24px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/article/index.tsx b/apps/miniprogram/src/pages/article/index.tsx index 8342df8..033a23d 100644 --- a/apps/miniprogram/src/pages/article/index.tsx +++ b/apps/miniprogram/src/pages/article/index.tsx @@ -1,12 +1,95 @@ +import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import '../health/index.scss'; +import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { listArticles, Article } from '../../services/article'; +import './index.scss'; + +export default function ArticleList() { + const [articles, setArticles] = useState([]); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async (p: number, append = false) => { + setLoading(true); + try { + const res = await listArticles(p); + const list = res.data || []; + setArticles(append ? (prev) => [...prev, ...list] : list); + setTotal(res.total); + setPage(p); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + setLoading(false); + } + }, []); + + useDidShow(() => { + fetchData(1); + }, [fetchData]); + + usePullDownRefresh(() => { + fetchData(1).finally(() => { + Taro.stopPullDownRefresh(); + }); + }); + + useReachBottom(() => { + if (!loading && articles.length < total) { + fetchData(page + 1, true); + } + }); + + const goToDetail = (id: string) => { + Taro.navigateTo({ url: `/pages/article/detail/index?id=${id}` }); + }; -export default function Article() { return ( - - 📰 - 健康资讯 - 科普文章、健康知识 + + + {articles.map((a) => ( + goToDetail(a.id)} + > + + {a.title} + {a.summary && ( + {a.summary} + )} + + {a.category && ( + {a.category} + )} + {a.published_at && ( + + {a.published_at.slice(0, 10)} + + )} + + + {a.cover_image && ( + + + + )} + + ))} + + + {articles.length === 0 && !loading && ( + + 暂无资讯文章 + + )} + + {loading && ( + + 加载中... + + )} ); } diff --git a/apps/miniprogram/src/pages/followup/detail/index.scss b/apps/miniprogram/src/pages/followup/detail/index.scss new file mode 100644 index 0000000..16b054e --- /dev/null +++ b/apps/miniprogram/src/pages/followup/detail/index.scss @@ -0,0 +1,118 @@ +@import '../../../styles/variables.scss'; + +.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: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.detail-title { + font-size: 34px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 20px; +} + +.detail-row { + display: flex; + justify-content: space-between; + padding: 12px 0; + border-bottom: 1px solid $bd-l; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-size: 26px; + color: $tx2; +} + +.detail-value { + font-size: 26px; + color: $tx; +} + +.detail-desc { + margin-top: 16px; + padding: 20px; + background: $bd-l; + border-radius: $r-sm; +} + +.detail-desc-text { + font-size: 26px; + color: $tx; + line-height: 1.6; +} + +.submit-card { + background: $card; + border-radius: $r; + padding: 28px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.section-title { + font-size: 30px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 16px; +} + +.submit-textarea { + width: 100%; + min-height: 200px; + font-size: 28px; + color: $tx; + background: $bd-l; + border-radius: $r-sm; + padding: 20px; + box-sizing: border-box; + border: none; + outline: none; + margin-bottom: 20px; +} + +.submit-btn { + background: $pri; + border-radius: $r-sm; + padding: 24px; + text-align: center; + + &.disabled { + opacity: 0.6; + } +} + +.submit-btn-text { + font-size: 30px; + color: white; + font-weight: bold; +} + +.loading-state, +.empty-state { + display: flex; + justify-content: center; + align-items: center; + padding: 120px 0; +} + +.loading-text, +.empty-text { + font-size: 28px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/followup/detail/index.tsx b/apps/miniprogram/src/pages/followup/detail/index.tsx new file mode 100644 index 0000000..e6bd428 --- /dev/null +++ b/apps/miniprogram/src/pages/followup/detail/index.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useRouter } from '@tarojs/taro'; +import { listTasks, submitRecord, FollowUpTask } from '../../../services/followup'; +import './index.scss'; + +export default function FollowUpDetail() { + const router = useRouter(); + const id = router.params.id || ''; + + const [task, setTask] = useState(null); + const [content, setContent] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + setLoading(true); + listTasks() + .then((res) => { + const found = (res.data || []).find((t) => t.id === id); + setTask(found || null); + }) + .catch(() => Taro.showToast({ title: '加载失败', icon: 'none' })) + .finally(() => setLoading(false)); + }, [id]); + + const handleSubmit = async () => { + if (!content.trim()) { + Taro.showToast({ title: '请输入内容', icon: 'none' }); + return; + } + setSubmitting(true); + try { + await submitRecord({ + task_id: id, + content: { text: content.trim() }, + }); + Taro.showToast({ title: '提交成功', icon: 'success' }); + setContent(''); + } catch { + Taro.showToast({ title: '提交失败', icon: 'none' }); + } finally { + setSubmitting(false); + } + }; + + const getStatusLabel = (status: string) => { + if (status === 'completed') return '已完成'; + if (status === 'overdue') return '已过期'; + return '待完成'; + }; + + if (loading) { + return ( + + + 加载中... + + + ); + } + + if (!task) { + return ( + + + 任务不存在 + + + ); + } + + const isCompleted = task.status === 'completed'; + + return ( + + {/* 任务详情 */} + + {task.task_type} + + 状态 + {getStatusLabel(task.status)} + + + 截止日期 + {task.due_date} + + {task.description && ( + + {task.description} + + )} + + + {/* 提交表单 */} + {!isCompleted && ( + + 填写随访记录 +