diff --git a/apps/miniprogram/src/app.config.ts b/apps/miniprogram/src/app.config.ts index 7433d87..66122e6 100644 --- a/apps/miniprogram/src/app.config.ts +++ b/apps/miniprogram/src/app.config.ts @@ -5,6 +5,7 @@ export default defineAppConfig({ 'pages/health/index', 'pages/health/input/index', 'pages/health/trend/index', + 'pages/health/daily-monitoring/index', 'pages/appointment/index', 'pages/appointment/create/index', 'pages/appointment/detail/index', @@ -14,6 +15,9 @@ export default defineAppConfig({ 'pages/followup/detail/index', 'pages/consultation/index', 'pages/mall/index', + 'pages/mall/exchange/index', + 'pages/mall/orders/index', + 'pages/mall/detail/index', 'pages/profile/index', 'pages/profile/family/index', 'pages/profile/family-add/index', diff --git a/apps/miniprogram/src/pages/consultation/index.scss b/apps/miniprogram/src/pages/consultation/index.scss index 807818f..7ddcc4b 100644 --- a/apps/miniprogram/src/pages/consultation/index.scss +++ b/apps/miniprogram/src/pages/consultation/index.scss @@ -26,7 +26,24 @@ display: block; } -.consultation-placeholder { +// ---- Loading / Error / Empty ---- + +.consultation-loading { + padding: 120px 0; +} + +.consultation-error { + display: flex; + justify-content: center; + padding: 120px 40px; +} + +.consultation-error-text { + font-size: 26px; + color: $dan; +} + +.consultation-empty { display: flex; flex-direction: column; align-items: center; @@ -34,20 +51,121 @@ padding: 160px 40px; } -.consultation-placeholder-icon { +.consultation-empty-icon { font-size: 100px; margin-bottom: 32px; } -.consultation-placeholder-text { +.consultation-empty-text { font-size: 36px; font-weight: bold; color: $tx; margin-bottom: 16px; } -.consultation-placeholder-hint { +.consultation-empty-hint { font-size: 26px; color: $tx3; text-align: center; } + +// ---- Session List ---- + +.consultation-list { + padding: 16px 24px; +} + +.consultation-session { + display: flex; + align-items: center; + background: $card; + border-radius: $r; + padding: 24px; + margin-bottom: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: transform 0.15s; + + &:active { + transform: scale(0.98); + } +} + +.session-left { + flex: 1; + min-width: 0; +} + +.session-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.session-subject { + font-size: 28px; + color: $tx; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 12px; +} + +.session-status-active { + font-size: 22px; + color: $acc; + font-weight: 500; + white-space: nowrap; +} + +.session-status-pending { + font-size: 22px; + color: $wrn; + font-weight: 500; + white-space: nowrap; +} + +.session-status-closed { + font-size: 22px; + color: $tx3; + white-space: nowrap; +} + +.session-message { + font-size: 26px; + color: $tx2; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-bottom: 8px; +} + +.session-time { + font-size: 22px; + color: $tx3; + display: block; +} + +// ---- Unread Badge ---- + +.session-badge { + background: $dan; + border-radius: 999px; + min-width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 8px; + margin-left: 12px; + flex-shrink: 0; +} + +.session-badge-text { + font-size: 22px; + color: white; + font-weight: bold; +} diff --git a/apps/miniprogram/src/pages/consultation/index.tsx b/apps/miniprogram/src/pages/consultation/index.tsx index 1d8b18e..201a160 100644 --- a/apps/miniprogram/src/pages/consultation/index.tsx +++ b/apps/miniprogram/src/pages/consultation/index.tsx @@ -1,12 +1,79 @@ +import { useState } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import { listConsultations, ConsultationSession } from '@/services/consultation'; +import Loading from '../../components/Loading'; import './index.scss'; +function getStatusLabel(status: string): string { + const map: Record = { + 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 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); + + if (diffMin < 1) return '刚刚'; + if (diffMin < 60) return `${diffMin}分钟前`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}小时前`; + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 7) return `${diffDay}天前`; + + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${m}-${day}`; +} + export default function Consultation() { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const loadSessions = async () => { + setLoading(true); + setError(''); + try { + const resp = await listConsultations({ page: 1, page_size: 20 }); + setSessions(resp.data || []); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '加载失败'; + setError(msg); + } finally { + setLoading(false); + } + }; + useDidShow(() => { Taro.setNavigationBarTitle({ title: '在线咨询' }); + loadSessions(); }); + usePullDownRefresh(() => { + loadSessions().finally(() => { + Taro.stopPullDownRefresh(); + }); + }); + + const handleTapSession = (_session: ConsultationSession) => { + Taro.showToast({ title: '即将上线', icon: 'none' }); + }; + return ( @@ -14,11 +81,57 @@ export default function Consultation() { 随时随地,连接专业医生 - - 💬 - 即将上线 - 在线咨询功能正在开发中,敬请期待 - + {loading ? ( + + + + ) : error ? ( + + {error} + + ) : sessions.length === 0 ? ( + + 💬 + 暂无咨询记录 + 在线咨询功能即将上线,敬请期待 + + ) : ( + + {sessions.map((session) => ( + handleTapSession(session)} + > + + + + {session.subject || '在线咨询'} + + + {getStatusLabel(session.status)} + + + + {session.last_message || '暂无消息'} + + + {session.last_message_at + ? formatTime(session.last_message_at) + : formatTime(session.created_at)} + + + {session.unread_count > 0 && ( + + + {session.unread_count > 99 ? '99+' : session.unread_count} + + + )} + + ))} + + )} ); } diff --git a/apps/miniprogram/src/pages/health/daily-monitoring/index.scss b/apps/miniprogram/src/pages/health/daily-monitoring/index.scss new file mode 100644 index 0000000..dfe6535 --- /dev/null +++ b/apps/miniprogram/src/pages/health/daily-monitoring/index.scss @@ -0,0 +1,140 @@ +@import '../../../styles/variables.scss'; + +.dm-page { + min-height: 100vh; + background: $bg; + padding: 24px; + padding-bottom: 60px; +} + +.dm-section { + background: $card; + border-radius: $r; + padding: 24px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.dm-section-title { + font-size: 28px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 16px; + padding-left: 12px; + border-left: 4px solid $pri; +} + +.dm-date-picker { + display: flex; + justify-content: space-between; + align-items: center; + background: $bg; + border-radius: $r-sm; + padding: 20px 24px; +} + +.dm-date-value { + font-size: 28px; + color: $pri; + font-weight: bold; +} + +.dm-date-arrow { + font-size: 28px; + color: $tx3; +} + +.dm-bp-row { + display: flex; + align-items: flex-end; + gap: 16px; +} + +.dm-bp-field { + flex: 1; +} + +.dm-field-label { + font-size: 24px; + color: $tx2; + display: block; + margin-bottom: 8px; +} + +.dm-bp-sep { + font-size: 40px; + color: $tx3; + padding-bottom: 16px; + font-weight: 300; +} + +.dm-field-unit { + font-size: 22px; + color: $tx3; + display: block; + margin-top: 8px; +} + +.dm-single-row { + display: flex; + align-items: center; + gap: 16px; +} + +.dm-field-unit-inline { + font-size: 26px; + color: $tx3; + white-space: nowrap; + flex-shrink: 0; +} + +.dm-input { + background: $bg; + border-radius: $r-sm; + padding: 20px 24px; + font-size: 28px; + color: $tx; + width: 100%; + box-sizing: border-box; +} + +.dm-input-full { + width: 100%; +} + +.dm-submit { + background: $pri; + border-radius: $r-sm; + padding: 24px; + text-align: center; + margin-top: 40px; + box-shadow: 0 4px 12px rgba(8, 145, 178, 0.3); + transition: opacity 0.2s; + + &:active { + opacity: 0.85; + } +} + +.dm-submit-disabled { + opacity: 0.6; + box-shadow: none; +} + +.dm-submit-text { + font-size: 32px; + color: white; + font-weight: bold; +} + +.dm-reset { + text-align: center; + padding: 20px; + margin-top: 16px; +} + +.dm-reset-text { + font-size: 26px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx b/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx new file mode 100644 index 0000000..877763a --- /dev/null +++ b/apps/miniprogram/src/pages/health/daily-monitoring/index.tsx @@ -0,0 +1,287 @@ +import { useState } from 'react'; +import { View, Text, Input, Picker } from '@tarojs/components'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { createDailyMonitoring } from '@/services/health'; +import { useAuthStore } from '@/stores/auth'; +import { trackEvent } from '@/services/analytics'; +import './index.scss'; + +function formatDate(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +export default function DailyMonitoring() { + const { currentPatient } = useAuthStore(); + + const today = formatDate(new Date()); + const [dateIdx, setDateIdx] = useState(0); + const [dateList] = useState(() => { + const list: string[] = []; + for (let i = 0; i < 30; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + list.push(formatDate(d)); + } + return list; + }); + const recordDate = dateList[dateIdx]; + + const [morningSystolic, setMorningSystolic] = useState(''); + const [morningDiastolic, setMorningDiastolic] = useState(''); + const [eveningSystolic, setEveningSystolic] = useState(''); + const [eveningDiastolic, setEveningDiastolic] = useState(''); + const [weight, setWeight] = useState(''); + const [bloodSugar, setBloodSugar] = useState(''); + const [fluidIntake, setFluidIntake] = useState(''); + const [urineOutput, setUrineOutput] = useState(''); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '日常监测上报' }); + }); + + const resetForm = () => { + setMorningSystolic(''); + setMorningDiastolic(''); + setEveningSystolic(''); + setEveningDiastolic(''); + setWeight(''); + setBloodSugar(''); + setFluidIntake(''); + setUrineOutput(''); + setNotes(''); + }; + + const handleSubmit = async () => { + if (!currentPatient) { + Taro.showToast({ title: '请先选择就诊人', icon: 'none' }); + return; + } + + const hasData = + morningSystolic || morningDiastolic || + eveningSystolic || eveningDiastolic || + weight || bloodSugar || fluidIntake || urineOutput; + + if (!hasData) { + Taro.showToast({ title: '请至少填写一项数据', icon: 'none' }); + return; + } + + if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) { + Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' }); + return; + } + if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) { + Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' }); + return; + } + + setSubmitting(true); + try { + await createDailyMonitoring({ + patient_id: currentPatient.id, + record_date: recordDate, + morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined, + morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined, + evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined, + evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined, + weight: weight ? parseFloat(weight) : undefined, + blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined, + fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined, + urine_output: urineOutput ? parseFloat(urineOutput) : undefined, + notes: notes || undefined, + }); + + trackEvent('daily_monitoring_submit', { date: recordDate }); + Taro.showToast({ title: '上报成功', icon: 'success' }); + + setTimeout(() => { + Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 }); + }, 1600); + + setTimeout(() => { + Taro.navigateBack(); + }, 3200); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : '上报失败'; + if (msg.includes('已有记录') || msg.includes('already exists')) { + Taro.showModal({ + title: '提示', + content: '该日期已有监测记录,请选择其他日期', + showCancel: false, + }); + } else { + Taro.showToast({ title: msg, icon: 'none' }); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + {/* 日期选择 */} + + 记录日期 + setDateIdx(Number(e.detail.value))} + > + + {recordDate} + + + + + + {/* 晨起血压 */} + + 晨起血压 + + + 收缩压 + setMorningSystolic(e.detail.value)} + /> + + / + + 舒张压 + setMorningDiastolic(e.detail.value)} + /> + + + mmHg + + + {/* 晚间血压 */} + + 晚间血压 + + + 收缩压 + setEveningSystolic(e.detail.value)} + /> + + / + + 舒张压 + setEveningDiastolic(e.detail.value)} + /> + + + mmHg + + + {/* 体重 */} + + 体重 + + setWeight(e.detail.value)} + /> + kg + + + + {/* 血糖 */} + + 血糖 + + setBloodSugar(e.detail.value)} + /> + mmol/L + + + + {/* 饮水量 */} + + 饮水量 + + setFluidIntake(e.detail.value)} + /> + ml + + + + {/* 尿量 */} + + 尿量 + + setUrineOutput(e.detail.value)} + /> + ml + + + + {/* 备注 */} + + 备注 + setNotes(e.detail.value)} + /> + + + {/* 提交 */} + + {submitting ? '提交中...' : '提交上报'} + + + {/* 重置 */} + + 清空表单 + + + ); +} diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index acb9e7b..7abb44a 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -31,6 +31,113 @@ font-weight: bold; } +// ---- Quick Actions (快捷操作) ---- + +.quick-actions { + display: flex; + gap: 16px; + padding: 0 24px; + margin-bottom: 24px; +} + +.quick-action-item { + flex: 1; + background: $card; + border-radius: $r; + padding: 24px 12px; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + transition: transform 0.15s; + + &:active { + transform: scale(0.96); + } +} + +.quick-action-icon-wrap { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; +} + +.quick-action-icon-primary { + background: $pri-l; +} + +.quick-action-icon-green { + background: $acc-l; +} + +.quick-action-icon-orange { + background: $wrn-l; +} + +.quick-action-icon { + font-size: 36px; +} + +.quick-action-label { + font-size: 24px; + color: $tx; + font-weight: 500; +} + +// ---- Checkin Status (打卡状态) ---- + +.checkin-card { + display: flex; + align-items: center; + justify-content: space-between; + background: $card; + border-radius: $r; + padding: 24px; + margin: 0 24px 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.checkin-left { + display: flex; + flex-direction: column; + gap: 4px; +} + +.checkin-done { + font-size: 28px; + color: $acc; + font-weight: bold; +} + +.checkin-streak { + font-size: 22px; + color: $tx3; +} + +.checkin-pending { + font-size: 28px; + color: $tx2; + font-weight: 500; +} + +.checkin-go-btn { + background: $pri; + border-radius: $r-sm; + padding: 12px 24px; +} + +.checkin-go-text { + font-size: 24px; + color: white; + font-weight: bold; +} + +// ---- Health Grid (体征概览) ---- + .health-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -90,6 +197,8 @@ margin-top: 4px; } +// ---- Trend Actions (趋势快捷入口) ---- + .health-actions { display: flex; gap: 16px; @@ -116,3 +225,64 @@ font-size: 24px; color: $tx2; } + +// ---- Recent Daily Monitoring (最近日常监测) ---- + +.recent-section { + padding: 0 24px; + margin-top: 32px; +} + +.recent-section-title { + font-size: 28px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 16px; + padding-left: 12px; + border-left: 4px solid $pri; +} + +.recent-record { + background: $card; + border-radius: $r; + padding: 20px 24px; + margin-bottom: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.recent-record-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.recent-record-date { + font-size: 26px; + color: $pri; + font-weight: bold; +} + +.recent-record-data { + display: flex; + gap: 32px; + flex-wrap: wrap; +} + +.recent-data-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.recent-data-label { + font-size: 22px; + color: $tx3; +} + +.recent-data-value { + font-size: 26px; + color: $tx; + font-weight: 500; +} diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index e61f9d2..d19b4f7 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -1,6 +1,11 @@ +import { useState } from 'react'; import { View, Text } from '@tarojs/components'; import Taro, { useDidShow } from '@tarojs/taro'; import { useHealthStore } from '../../stores/health'; +import { listDailyMonitoring, DailyMonitoring } from '../../services/health'; +import { getCheckinStatus, CheckinStatus } from '../../services/points'; +import { useAuthStore } from '../../stores/auth'; +import { trackEvent } from '../../services/analytics'; import Loading from '../../components/Loading'; import './index.scss'; @@ -13,19 +18,53 @@ function getStatusStyle(status?: string) { export default function Health() { const { todaySummary, loading, refreshToday } = useHealthStore(); + const { currentPatient } = useAuthStore(); + const [checkinStatus, setCheckinStatus] = useState(null); + const [recentRecords, setRecentRecords] = useState([]); useDidShow(() => { refreshToday(); + loadExtraData(); }); + const loadExtraData = async () => { + try { + const status = await getCheckinStatus(); + setCheckinStatus(status); + } catch { + // ignore — points API may not be available + } + + if (currentPatient) { + try { + const resp = await listDailyMonitoring(currentPatient.id, { page: 1, page_size: 3 }); + setRecentRecords(resp.data || []); + } catch { + // ignore — daily monitoring API may not be available yet + } + } + }; + const goToInput = () => { Taro.navigateTo({ url: '/pages/health/input/index' }); }; + const goToDailyMonitoring = () => { + Taro.navigateTo({ url: '/pages/health/daily-monitoring/index' }); + }; + const goToTrend = (indicator: string) => { Taro.navigateTo({ url: `/pages/health/trend/index?indicator=${indicator}` }); }; + const goToTrendPage = () => { + Taro.navigateTo({ url: '/pages/health/trend/index?indicator=blood_pressure_systolic' }); + }; + + const goToMall = () => { + Taro.switchTab({ url: '/pages/mall/index' }); + }; + const summary = todaySummary || {}; const items = [ { label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '--/--', unit: 'mmHg', indicator: 'blood_pressure_systolic', status: summary.blood_pressure?.status, ref: summary.blood_pressure?.reference_range }, @@ -34,6 +73,17 @@ export default function Health() { { label: '体重', value: summary.weight ? `${summary.weight.value}` : '--', unit: 'kg', indicator: 'weight', status: summary.weight?.status, ref: summary.weight?.reference_range }, ]; + const formatBp = (record: DailyMonitoring) => { + const parts: string[] = []; + if (record.morning_bp_systolic && record.morning_bp_diastolic) { + parts.push(`晨 ${record.morning_bp_systolic}/${record.morning_bp_diastolic}`); + } + if (record.evening_bp_systolic && record.evening_bp_diastolic) { + parts.push(`晚 ${record.evening_bp_systolic}/${record.evening_bp_diastolic}`); + } + return parts.length > 0 ? parts.join(' ') : '--'; + }; + return ( @@ -43,6 +93,52 @@ export default function Health() { + {/* 快捷操作 */} + + + + 📋 + + 日常上报 + + + + 💉 + + 体征录入 + + + + 📈 + + 查看趋势 + + + + {/* 打卡状态 */} + {checkinStatus && ( + + + {checkinStatus.checked_in_today ? ( + <> + 今日已打卡 ✓ + {checkinStatus.consecutive_days > 0 && ( + 连续 {checkinStatus.consecutive_days} 天 + )} + + ) : ( + 今日未打卡 + )} + + {!checkinStatus.checked_in_today && ( + + 去打卡 + + )} + + )} + + {/* 今日体征概览 */} {loading && !todaySummary ? ( ) : ( @@ -64,6 +160,7 @@ export default function Health() { )} + {/* 原有趋势快捷入口 */} goToTrend('blood_pressure_systolic')}> 📈 @@ -78,6 +175,38 @@ export default function Health() { 血糖趋势 + + {/* 最近日常监测记录 */} + {recentRecords.length > 0 && ( + + 最近监测记录 + {recentRecords.map((record) => ( + + + {record.record_date} + + + + 血压 + {formatBp(record)} + + {record.weight != null && ( + + 体重 + {record.weight} kg + + )} + {record.blood_sugar != null && ( + + 血糖 + {record.blood_sugar} mmol/L + + )} + + + ))} + + )} ); } diff --git a/apps/miniprogram/src/pages/mall/detail/index.scss b/apps/miniprogram/src/pages/mall/detail/index.scss new file mode 100644 index 0000000..329c323 --- /dev/null +++ b/apps/miniprogram/src/pages/mall/detail/index.scss @@ -0,0 +1,219 @@ +@import '../../../styles/variables.scss'; + +.detail-page { + min-height: 100vh; + background: $bg; + padding-bottom: 40px; +} + +/* ===== 余额卡片 ===== */ +.balance-card { + background: linear-gradient(135deg, $pri 0%, $pri-d 100%); + padding: 32px; + padding-top: 40px; +} + +.balance-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.balance-label { + font-size: 26px; + color: rgba(255, 255, 255, 0.85); +} + +.balance-value { + font-size: 56px; + font-weight: bold; + color: white; + letter-spacing: 1px; +} + +.balance-stats { + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.12); + border-radius: $r; + padding: 20px 0; +} + +.stat-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; +} + +.stat-value { + font-size: 30px; + font-weight: bold; + color: white; + margin-bottom: 4px; + + &.green { + color: #A7F3D0; + } + + &.orange { + color: #FDE68A; + } + + &.gray { + color: rgba(255, 255, 255, 0.6); + } +} + +.stat-label { + font-size: 22px; + color: rgba(255, 255, 255, 0.7); +} + +.stat-divider { + width: 1px; + height: 48px; + background: rgba(255, 255, 255, 0.2); +} + +/* ===== 类型筛选标签 ===== */ +.type-tabs { + display: flex; + gap: 0; + padding: 20px 24px 0; + background: $card; + margin-bottom: 16px; +} + +.type-tab { + flex: 1; + text-align: center; + padding: 16px 0; + position: relative; + + &.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 48px; + height: 4px; + background: $pri; + border-radius: 2px; + } +} + +.type-tab-text { + font-size: 28px; + color: $tx2; + + &.active { + color: $pri; + font-weight: bold; + } +} + +/* ===== 交易列表 ===== */ +.transaction-list { + padding: 0 24px; +} + +.transaction-item { + display: flex; + align-items: center; + background: $card; + border-radius: $r; + padding: 24px; + margin-bottom: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); +} + +.tx-icon { + width: 72px; + height: 72px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20px; + flex-shrink: 0; + + &.type-earn { + background: $acc-l; + } + + &.type-spend { + background: $dan-l; + } + + &.type-expired { + background: $bd-l; + } +} + +.tx-icon-text { + font-size: 32px; + font-weight: bold; + + .type-earn & { + color: $acc; + } + + .type-spend & { + color: $dan; + } + + .type-expired & { + color: $tx3; + } +} + +.tx-info { + flex: 1; + min-width: 0; +} + +.tx-desc { + font-size: 28px; + color: $tx; + display: block; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tx-date { + font-size: 22px; + color: $tx3; + display: block; +} + +.tx-amount-col { + display: flex; + flex-direction: column; + align-items: flex-end; + margin-left: 16px; + flex-shrink: 0; +} + +.tx-amount { + font-size: 32px; + font-weight: bold; + margin-bottom: 4px; + + &.positive { + color: $acc; + } + + &.negative { + color: $dan; + } +} + +.tx-remaining { + font-size: 20px; + color: $tx3; +} diff --git a/apps/miniprogram/src/pages/mall/detail/index.tsx b/apps/miniprogram/src/pages/mall/detail/index.tsx new file mode 100644 index 0000000..0225a76 --- /dev/null +++ b/apps/miniprogram/src/pages/mall/detail/index.tsx @@ -0,0 +1,199 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { getAccount, listMyTransactions } from '../../../services/points'; +import type { PointsAccount, PointsTransaction } from '../../../services/points'; +import EmptyState from '../../../components/EmptyState'; +import Loading from '../../../components/Loading'; +import './index.scss'; + +const TYPE_TABS = [ + { key: '', label: '全部' }, + { key: 'earn', label: '收入' }, + { key: 'spend', label: '支出' }, +]; + +const TYPE_ICONS: Record = { + earn: { icon: '↑', className: 'type-earn' }, + spend: { icon: '↓', className: 'type-spend' }, + expired: { icon: '⏰', className: 'type-expired' }, +}; + +export default function PointsDetail() { + const [account, setAccount] = useState(null); + const [transactions, setTransactions] = useState([]); + const [activeTab, setActiveTab] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const loadingRef = useRef(false); + + const fetchAccount = useCallback(async () => { + try { + const acct = await getAccount(); + setAccount(acct); + } catch { + // 账户可能尚未创建 + } + }, []); + + const fetchTransactions = useCallback( + async (pageNum: number, type: string, isRefresh = false) => { + if (loadingRef.current) return; + loadingRef.current = true; + setLoading(true); + try { + const res = await listMyTransactions({ + page: pageNum, + page_size: 10, + }); + let list = res.data || []; + // 前端按类型过滤(后端暂不支持 type 参数) + if (type) { + list = list.filter((t) => t.type === type); + } + if (isRefresh) { + setTransactions(list); + } else { + setTransactions((prev) => [...prev, ...list]); + } + setTotal(res.total); + setPage(pageNum); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + loadingRef.current = false; + setLoading(false); + } + }, + [], + ); + + const loadAll = useCallback( + async (type?: string) => { + const t = type !== undefined ? type : activeTab; + await Promise.all([fetchAccount(), fetchTransactions(1, t, true)]); + }, + [fetchAccount, fetchTransactions, activeTab], + ); + + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '积分明细' }); + loadAll(); + }); + + usePullDownRefresh(() => { + loadAll().finally(() => { + Taro.stopPullDownRefresh(); + }); + }); + + useReachBottom(() => { + if (!loading && transactions.length < total) { + fetchTransactions(page + 1, activeTab); + } + }); + + const handleTabChange = (key: string) => { + setActiveTab(key); + fetchTransactions(1, key, true); + }; + + const getTypeConfig = (type: string) => { + return TYPE_ICONS[type] || { icon: '?', className: 'type-earn' }; + }; + + const formatAmount = (tx: PointsTransaction) => { + const amt = tx.amount; + if (tx.type === 'earn') return `+${amt.toLocaleString()}`; + if (tx.type === 'spend') return `-${amt.toLocaleString()}`; + return `-${amt.toLocaleString()}`; + }; + + const formatDate = (dateStr: string) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + }; + + const balance = account?.balance ?? 0; + + return ( + + {/* 余额卡片 */} + + + 当前积分 + {balance.toLocaleString()} + + + + {(account?.total_earned ?? 0).toLocaleString()} + 累计获得 + + + + {(account?.total_spent ?? 0).toLocaleString()} + 累计消费 + + + + {(account?.total_expired ?? 0).toLocaleString()} + 已过期 + + + + + {/* 类型筛选标签 */} + + {TYPE_TABS.map((tab) => ( + handleTabChange(tab.key)} + > + + {tab.label} + + + ))} + + + {/* 交易列表 */} + {transactions.length === 0 && !loading ? ( + + ) : ( + + {transactions.map((tx) => { + const typeCfg = getTypeConfig(tx.type); + return ( + + + {typeCfg.icon} + + + + {tx.description || (tx.type === 'earn' ? '积分收入' : tx.type === 'spend' ? '积分消费' : '积分过期')} + + {formatDate(tx.created_at)} + + + + {formatAmount(tx)} + + 余额 {tx.balance_after.toLocaleString()} + + + ); + })} + {loading && } + {!loading && transactions.length >= total && total > 0 && ( + + )} + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/mall/exchange/index.scss b/apps/miniprogram/src/pages/mall/exchange/index.scss new file mode 100644 index 0000000..c640398 --- /dev/null +++ b/apps/miniprogram/src/pages/mall/exchange/index.scss @@ -0,0 +1,178 @@ +@import '../../../styles/variables.scss'; + +.exchange-page { + min-height: 100vh; + background: $bg; + padding-bottom: 140px; +} + +/* ===== 商品预览 ===== */ +.product-preview { + display: flex; + align-items: center; + padding: 32px 24px; + background: $card; + margin-bottom: 16px; +} + +.preview-image { + width: 160px; + height: 160px; + border-radius: $r; + display: flex; + align-items: center; + justify-content: center; + margin-right: 24px; + flex-shrink: 0; +} + +.preview-icon { + font-size: 64px; +} + +.preview-info { + flex: 1; + min-width: 0; +} + +.preview-name { + font-size: 32px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-type { + font-size: 24px; + color: $tx3; + display: block; +} + +/* ===== 兑换详情 ===== */ +.exchange-detail { + background: $card; + padding: 0 24px; + margin-bottom: 16px; +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 0; + border-bottom: 1px solid $bd-l; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-size: 28px; + color: $tx2; +} + +.detail-value { + font-size: 28px; + color: $tx; + font-weight: bold; + + &.cost { + color: $wrn; + font-size: 32px; + } + + &.sufficient { + color: $acc; + } + + &.insufficient { + color: $dan; + } +} + +/* ===== 温馨提示 ===== */ +.exchange-notice { + background: $card; + padding: 24px; +} + +.notice-title { + font-size: 28px; + font-weight: bold; + color: $tx; + display: block; + margin-bottom: 12px; +} + +.notice-text { + font-size: 24px; + color: $tx3; + display: block; + line-height: 1.6; + margin-bottom: 4px; +} + +/* ===== 底部操作栏 ===== */ +.exchange-footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + padding: 16px 24px; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + background: $card; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); + z-index: 10; +} + +.footer-cost { + flex: 1; + display: flex; + flex-direction: column; +} + +.footer-cost-label { + font-size: 22px; + color: $tx3; +} + +.footer-cost-value { + display: flex; + align-items: center; + gap: 4px; +} + +.footer-cost-icon { + font-size: 24px; +} + +.footer-cost-num { + font-size: 36px; + font-weight: bold; + color: $wrn; +} + +.confirm-btn { + background: $pri; + padding: 20px 48px; + border-radius: $r; + transition: opacity 0.2s; + + &.disabled { + background: $tx3; + opacity: 0.6; + } +} + +.confirm-btn-text { + font-size: 30px; + color: white; + font-weight: bold; +} diff --git a/apps/miniprogram/src/pages/mall/exchange/index.tsx b/apps/miniprogram/src/pages/mall/exchange/index.tsx new file mode 100644 index 0000000..da3a0f0 --- /dev/null +++ b/apps/miniprogram/src/pages/mall/exchange/index.tsx @@ -0,0 +1,207 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text } from '@tarojs/components'; +import Taro, { useDidShow } from '@tarojs/taro'; +import { + getAccount, + listProducts, + exchangeProduct, +} from '../../../services/points'; +import type { PointsAccount, PointsProduct } from '../../../services/points'; +import Loading from '../../../components/Loading'; +import './index.scss'; + +const TYPE_ICONS: Record = { + physical: '📦', + service: '🎫', + privilege: '👑', +}; + +export default function ExchangeConfirm() { + const [product, setProduct] = useState(null); + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '确认兑换' }); + loadData(); + }); + + const loadData = useCallback(async () => { + const instance = Taro.getCurrentInstance(); + const productId = instance.router?.params?.product_id; + if (!productId) { + Taro.showToast({ title: '参数错误', icon: 'none' }); + setTimeout(() => Taro.navigateBack(), 1500); + return; + } + + setLoading(true); + try { + const [productRes, accountRes] = await Promise.all([ + listProducts({ page: 1, page_size: 100 }), + getAccount(), + ]); + const found = productRes.data.find((p) => p.id === productId); + if (!found) { + Taro.showToast({ title: '商品不存在', icon: 'none' }); + setTimeout(() => Taro.navigateBack(), 1500); + return; + } + setProduct(found); + setAccount(accountRes); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + setTimeout(() => Taro.navigateBack(), 1500); + } finally { + setLoading(false); + } + }, []); + + const balance = account?.balance ?? 0; + const cost = product?.points_cost ?? 0; + const insufficient = balance < cost; + + const handleConfirm = useCallback(async () => { + if (!product || submitting) return; + + if (insufficient) { + Taro.showToast({ title: '积分不足', icon: 'none' }); + return; + } + + const modalRes = await Taro.showModal({ + title: '确认兑换', + content: `确定花费 ${cost} 积分兑换「${product.name}」吗?`, + }); + if (!modalRes.confirm) return; + + setSubmitting(true); + try { + const order = await exchangeProduct(product.id); + Taro.showToast({ title: '兑换成功', icon: 'success', duration: 2000 }); + + // 展示核销码弹窗 + setTimeout(() => { + Taro.showModal({ + title: '兑换成功', + content: `核销码: ${order.qr_code}\n请凭此码到前台核销`, + showCancel: false, + confirmText: '查看订单', + success: () => { + Taro.navigateTo({ + url: `/pages/mall/orders/index`, + }); + }, + }); + }, 2000); + } catch (err) { + const msg = err instanceof Error ? err.message : '兑换失败'; + if (msg.includes('余额不足') || msg.includes('insufficient')) { + Taro.showToast({ title: '积分不足', icon: 'none' }); + } else { + Taro.showToast({ title: msg, icon: 'none' }); + } + } finally { + setSubmitting(false); + } + }, [product, submitting, insufficient, cost]); + + if (loading) { + return ( + + + + ); + } + + return ( + + {/* 商品信息卡片 */} + + + + {product ? TYPE_ICONS[product.product_type] || '🎁' : '🎁'} + + + + {product?.name || ''} + + {product?.product_type === 'physical' + ? '实物商品' + : product?.product_type === 'service' + ? '服务券' + : '权益卡'} + + + + + {/* 兑换详情 */} + + + 所需积分 + {cost.toLocaleString()} + + + 当前余额 + + {balance.toLocaleString()} + + + {insufficient && ( + + 差额 + + -{(cost - balance).toLocaleString()} + + + )} + + 库存 + + {product && product.stock > 0 ? `剩余 ${product.stock} 件` : '已兑完'} + + + + + {/* 温馨提示 */} + + 温馨提示 + + 兑换成功后将生成核销码,请凭核销码到前台核销领取。 + + 积分一经兑换不可退回。 + + + {/* 底部操作 */} + + + 合计 + + 🪙 + {cost.toLocaleString()} + + + + + {submitting + ? '兑换中...' + : insufficient + ? '积分不足' + : (product?.stock ?? 0) <= 0 + ? '已兑完' + : '确认兑换'} + + + + + ); +} diff --git a/apps/miniprogram/src/pages/mall/index.tsx b/apps/miniprogram/src/pages/mall/index.tsx index fa27f5f..b98b2cd 100644 --- a/apps/miniprogram/src/pages/mall/index.tsx +++ b/apps/miniprogram/src/pages/mall/index.tsx @@ -132,6 +132,14 @@ export default function Mall() { fetchProducts(1, key, true); }; + const handleProductClick = (item: PointsProduct) => { + if (item.stock <= 0) { + Taro.showToast({ title: '已兑完', icon: 'none' }); + return; + } + Taro.navigateTo({ url: `/pages/mall/exchange/index?product_id=${item.id}` }); + }; + const balance = account?.balance ?? 0; return ( @@ -194,7 +202,7 @@ export default function Mall() { ) : ( {products.map((item) => ( - + handleProductClick(item)}> = { + pending: { label: '待核销', className: 'status-pending' }, + verified: { label: '已核销', className: 'status-verified' }, + cancelled: { label: '已取消', className: 'status-cancelled' }, + expired: { label: '已过期', className: 'status-expired' }, +}; + +export default function MallOrders() { + const [orders, setOrders] = useState([]); + const [activeTab, setActiveTab] = useState(''); + const [page, setPage] = useState(1); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const loadingRef = useRef(false); + + const fetchOrders = useCallback( + async (pageNum: number, status: string, isRefresh = false) => { + if (loadingRef.current) return; + loadingRef.current = true; + setLoading(true); + try { + const res = await listMyOrders({ + page: pageNum, + page_size: 10, + }); + let list = res.data || []; + // 前端按状态过滤(后端暂不支持 status 参数) + if (status) { + list = list.filter((o) => o.status === status); + } + if (isRefresh) { + setOrders(list); + } else { + setOrders((prev) => [...prev, ...list]); + } + setTotal(res.total); + setPage(pageNum); + } catch { + Taro.showToast({ title: '加载失败', icon: 'none' }); + } finally { + loadingRef.current = false; + setLoading(false); + } + }, + [], + ); + + const loadAll = useCallback( + async (status?: string) => { + const s = status !== undefined ? status : activeTab; + await fetchOrders(1, s, true); + }, + [fetchOrders, activeTab], + ); + + useDidShow(() => { + Taro.setNavigationBarTitle({ title: '我的订单' }); + loadAll(); + }); + + usePullDownRefresh(() => { + loadAll().finally(() => { + Taro.stopPullDownRefresh(); + }); + }); + + useReachBottom(() => { + if (!loading && orders.length < total) { + fetchOrders(page + 1, activeTab); + } + }); + + const handleTabChange = (key: string) => { + setActiveTab(key); + fetchOrders(1, key, true); + }; + + const handleShowQrCode = (qrCode: string) => { + Taro.showModal({ + title: '核销码', + content: qrCode, + showCancel: false, + confirmText: '知道了', + }); + }; + + const getStatusConfig = (status: string) => { + return STATUS_CONFIG[status] || { label: status, className: 'status-pending' }; + }; + + const formatDate = (dateStr: string) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + }; + + return ( + + {/* 状态筛选标签 */} + + {STATUS_TABS.map((tab) => ( + handleTabChange(tab.key)} + > + + {tab.label} + + + ))} + + + {/* 订单列表 */} + {orders.length === 0 && !loading ? ( + Taro.switchTab({ url: '/pages/mall/index' })} + /> + ) : ( + + {orders.map((order) => { + const statusCfg = getStatusConfig(order.status); + return ( + + + 商品 {order.product_id.slice(0, 8)} + + {statusCfg.label} + + + + + + 消耗积分 + + 🪙 {order.points_cost.toLocaleString()} + + + + 兑换时间 + + {formatDate(order.created_at)} + + + {order.status === 'pending' && ( + handleShowQrCode(order.qr_code)}> + 核销码: + {order.qr_code} + 点击查看 + + )} + + + ); + })} + {loading && } + {!loading && orders.length >= total && total > 0 && ( + + )} + + )} + + ); +} diff --git a/apps/miniprogram/src/pages/profile/index.scss b/apps/miniprogram/src/pages/profile/index.scss index 3169ecb..8faa824 100644 --- a/apps/miniprogram/src/pages/profile/index.scss +++ b/apps/miniprogram/src/pages/profile/index.scss @@ -42,6 +42,45 @@ color: rgba(255, 255, 255, 0.8); } +/* ===== 积分余额信息 ===== */ +.profile-points { + display: flex; + align-items: center; + margin-top: 24px; + background: rgba(255, 255, 255, 0.15); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: $r; + padding: 20px 32px; + width: 100%; + box-sizing: border-box; +} + +.points-info-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; +} + +.points-info-value { + font-size: 36px; + font-weight: bold; + color: white; + margin-bottom: 4px; +} + +.points-info-label { + font-size: 22px; + color: rgba(255, 255, 255, 0.7); +} + +.points-info-divider { + width: 1px; + height: 48px; + background: rgba(255, 255, 255, 0.25); + margin: 0 24px; +} + .profile-menu { margin: 24px; background: $card; diff --git a/apps/miniprogram/src/pages/profile/index.tsx b/apps/miniprogram/src/pages/profile/index.tsx index 0dc817a..7fb3c40 100644 --- a/apps/miniprogram/src/pages/profile/index.tsx +++ b/apps/miniprogram/src/pages/profile/index.tsx @@ -1,9 +1,14 @@ +import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; import Taro, { useDidShow } from '@tarojs/taro'; import { useAuthStore } from '../../stores/auth'; +import { getAccount, getCheckinStatus } from '../../services/points'; +import type { PointsAccount, CheckinStatus } from '../../services/points'; import './index.scss'; const MENU_ITEMS = [ + { label: '我的订单', icon: '🛒', path: '/pages/mall/orders/index' }, + { label: '积分明细', icon: '📊', path: '/pages/mall/detail/index' }, { label: '就诊人管理', icon: '👥', path: '/pages/profile/family/index' }, { label: '我的报告', icon: '📋', path: '/pages/profile/reports/index' }, { label: '我的随访', icon: '💬', path: '/pages/profile/followups/index' }, @@ -13,11 +18,27 @@ const MENU_ITEMS = [ export default function Profile() { const { user, restore: restoreAuth, logout } = useAuthStore(); + const [pointsAccount, setPointsAccount] = useState(null); + const [checkinInfo, setCheckinInfo] = useState(null); useDidShow(() => { restoreAuth(); + loadPointsInfo(); }); + const loadPointsInfo = useCallback(async () => { + try { + const [acct, status] = await Promise.all([ + getAccount(), + getCheckinStatus(), + ]); + setPointsAccount(acct); + setCheckinInfo(status); + } catch { + // 账户可能尚未创建,静默处理 + } + }, []); + const handleMenuClick = (path: string) => { Taro.navigateTo({ url: path }); }; @@ -43,6 +64,26 @@ export default function Profile() { {user?.display_name || '未登录'} {user?.phone || ''} + + {/* 积分余额信息 */} + Taro.navigateTo({ url: '/pages/mall/detail/index' })} + > + + + {(pointsAccount?.balance ?? 0).toLocaleString()} + + 积分 + + + + + {checkinInfo?.consecutive_days ?? 0} + + 连续打卡(天) + + diff --git a/apps/miniprogram/src/services/consultation.ts b/apps/miniprogram/src/services/consultation.ts new file mode 100644 index 0000000..7412dbd --- /dev/null +++ b/apps/miniprogram/src/services/consultation.ts @@ -0,0 +1,24 @@ +import { api } from './request'; + +export interface ConsultationSession { + id: string; + patient_id: string; + doctor_id: string | null; + type: string; + status: string; + subject: string | null; + last_message: string | null; + last_message_at: string | null; + unread_count: number; + created_at: string; +} + +export async function listConsultations(params?: { + page?: number; + page_size?: number; +}) { + return api.get<{ data: ConsultationSession[]; total: number }>( + '/health/consultation-sessions', + params, + ); +} diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts index a9bb6c7..591da8d 100644 --- a/apps/miniprogram/src/services/health.ts +++ b/apps/miniprogram/src/services/health.ts @@ -29,3 +29,51 @@ export async function getTrend(indicator: string, range: string) { { indicator, range }, ); } + +// ---- Daily Monitoring (日常监测) ---- + +export interface DailyMonitoring { + id: string; + patient_id: string; + record_date: string; + morning_bp_systolic: number | null; + morning_bp_diastolic: number | null; + evening_bp_systolic: number | null; + evening_bp_diastolic: number | null; + weight: number | null; + blood_sugar: number | null; + fluid_intake: number | null; + urine_output: number | null; + notes: string | null; + created_at: string; + updated_at: string; + version: number; +} + +export interface CreateDailyMonitoringReq { + patient_id: string; + record_date: string; + morning_bp_systolic?: number; + morning_bp_diastolic?: number; + evening_bp_systolic?: number; + evening_bp_diastolic?: number; + weight?: number; + blood_sugar?: number; + fluid_intake?: number; + urine_output?: number; + notes?: string; +} + +export async function createDailyMonitoring(data: CreateDailyMonitoringReq) { + return api.post('/health/daily-monitoring', data); +} + +export async function listDailyMonitoring( + patientId: string, + params?: { page?: number; page_size?: number }, +) { + return api.get<{ data: DailyMonitoring[]; total: number }>( + `/health/patients/${patientId}/daily-monitoring`, + params, + ); +} diff --git a/apps/miniprogram/src/services/points.ts b/apps/miniprogram/src/services/points.ts index 7c882c3..b891a51 100644 --- a/apps/miniprogram/src/services/points.ts +++ b/apps/miniprogram/src/services/points.ts @@ -55,3 +55,46 @@ export async function listProducts(params?: { }) { return api.get('/health/points/products', params); } + +// ===== 兑换订单 ===== + +export interface PointsOrder { + id: string; + patient_id: string; + product_id: string; + points_cost: number; + status: string; // pending / verified / cancelled / expired + qr_code: string; + verified_by: string | null; + verified_at: string | null; + expires_at: string | null; + notes: string | null; + created_at: string; + updated_at: string; + version: number; +} + +export interface PointsTransaction { + id: string; + account_id: string; + type: string; // earn / spend / expired + amount: number; + remaining_amount: number; + status: string; + expires_at: string | null; + balance_after: number; + description: string | null; + created_at: string; +} + +export async function exchangeProduct(product_id: string) { + return api.post('/health/points/exchange', { product_id }); +} + +export async function listMyOrders(params?: { page?: number; page_size?: number }) { + return api.get<{ data: PointsOrder[]; total: number }>('/health/points/orders', params); +} + +export async function listMyTransactions(params?: { page?: number; page_size?: number }) { + return api.get<{ data: PointsTransaction[]; total: number }>('/health/points/transactions', params); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fbc959d..7ddb7a6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -39,6 +39,7 @@ const PointsRuleList = lazy(() => import('./pages/health/PointsRuleList')); const PointsProductList = lazy(() => import('./pages/health/PointsProductList')); const PointsOrderList = lazy(() => import('./pages/health/PointsOrderList')); const OfflineEventList = lazy(() => import('./pages/health/OfflineEventList')); +const StatisticsDashboard = lazy(() => import('./pages/health/StatisticsDashboard')); function PrivateRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -170,6 +171,7 @@ export default function App() { } /> } /> {/* 健康管理 */} + } /> } /> } /> } /> diff --git a/apps/web/src/api/health/points.ts b/apps/web/src/api/health/points.ts index 86e3a12..185876c 100644 --- a/apps/web/src/api/health/points.ts +++ b/apps/web/src/api/health/points.ts @@ -118,6 +118,35 @@ export interface PointsStatistics { }>; } +export interface PatientStatistics { + total_patients: number; + new_this_month: number; + new_this_week: number; + active_this_month: number; +} + +export interface ConsultationStatistics { + total_sessions: number; + pending_reply: number; + avg_response_time_minutes: number | null; + this_month: number; +} + +export interface FollowUpStatistics { + total_tasks: number; + completed: number; + pending: number; + overdue: number; + completion_rate: number; +} + +export interface OverviewStatistics { + patients: PatientStatistics; + consultations: ConsultationStatistics; + follow_ups: FollowUpStatistics; + points: PointsStatistics; +} + // --- API --- export const pointsApi = { @@ -211,4 +240,33 @@ export const pointsApi = { }>('/health/admin/points/statistics'); return data.data; }, + + // --- Dashboard Statistics (hybrid: aggregate from list endpoints) --- + + getPatientStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse<{ id: string }>; + }>('/health/patients', { params: { page: 1, page_size: 1 } }); + const total = data.data?.total || 0; + return { total_patients: total, new_this_month: 0, new_this_week: 0, active_this_month: 0 }; + }, + + getConsultationStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse<{ id: string }>; + }>('/health/consultation-sessions', { params: { page: 1, page_size: 1 } }); + const total = data.data?.total || 0; + return { total_sessions: total, pending_reply: 0, avg_response_time_minutes: null, this_month: 0 }; + }, + + getFollowUpStats: async (): Promise => { + const { data } = await client.get<{ + success: boolean; + data: PaginatedResponse<{ id: string }>; + }>('/health/follow-up-tasks', { params: { page: 1, page_size: 1 } }); + const total = data.data?.total || 0; + return { total_tasks: total, completed: 0, pending: 0, overdue: 0, completion_rate: 0 }; + }, }; diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx index 84c53ad..389e24a 100644 --- a/apps/web/src/layouts/MainLayout.tsx +++ b/apps/web/src/layouts/MainLayout.tsx @@ -27,6 +27,7 @@ import { TrophyOutlined, ShopOutlined, FileTextOutlined, + DashboardOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; import { useAppStore } from '../stores/app'; @@ -56,6 +57,7 @@ const bizMenuItems: MenuItem[] = [ ]; const healthMenuItems: MenuItem[] = [ + { key: '/health/statistics', icon: , label: '统计报表' }, { key: '/health/patients', icon: , label: '患者管理' }, { key: '/health/doctors', icon: , label: '医护管理' }, { key: '/health/appointments', icon: , label: '预约排班' }, @@ -83,6 +85,7 @@ const routeTitleMap: Record = { '/messages': '消息中心', '/settings': '系统设置', '/plugins/admin': '插件管理', + '/health/statistics': '统计报表', '/health/patients': '患者管理', '/health/patients/:id': '患者详情', '/health/tags': '标签管理', diff --git a/apps/web/src/pages/health/StatisticsDashboard.tsx b/apps/web/src/pages/health/StatisticsDashboard.tsx new file mode 100644 index 0000000..76685a1 --- /dev/null +++ b/apps/web/src/pages/health/StatisticsDashboard.tsx @@ -0,0 +1,414 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Row, + Col, + Card, + Statistic, + Table, + Spin, + Alert, + Button, + Typography, + Tooltip, +} from 'antd'; +import { + UserOutlined, + MessageOutlined, + PhoneOutlined, + TrophyOutlined, + TeamOutlined, + CalendarOutlined, + ShoppingOutlined, + ReloadOutlined, + CommentOutlined, + MedicineBoxOutlined, + FileTextOutlined, + ClockCircleOutlined, + ArrowUpOutlined, +} from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import { + pointsApi, + type PatientStatistics, + type ConsultationStatistics, + type FollowUpStatistics, + type PointsStatistics, +} from '../../api/health/points'; + +const { Title: AntTitle, Text } = Typography; + +/** Top-level stat card configuration */ +interface StatCardConfig { + title: string; + value: number; + suffix?: string; + precision?: number; + prefix?: React.ReactNode; + subtitle?: string; + color: string; + bgColor: string; +} + +/** Quick-link card configuration */ +interface QuickLinkConfig { + title: string; + icon: React.ReactNode; + path: string; + color: string; +} + +/** Top earner row from points statistics */ +interface TopEarnerRow { + rank: number; + patient_id: string; + total_earned: number; +} + +const QUICK_LINKS: QuickLinkConfig[] = [ + { title: '患者管理', icon: , path: '/health/patients', color: '#2563eb' }, + { title: '预约排班', icon: , path: '/health/appointments', color: '#059669' }, + { title: '随访管理', icon: , path: '/health/follow-up-tasks', color: '#d97706' }, + { title: '咨询管理', icon: , path: '/health/consultations', color: '#7c3aed' }, + { title: '积分规则', icon: , path: '/health/points-rules', color: '#dc2626' }, + { title: '商品管理', icon: , path: '/health/points-products', color: '#0891b2' }, + { title: '订单管理', icon: , path: '/health/points-orders', color: '#4f46e5' }, + { title: '线下活动', icon: , path: '/health/offline-events', color: '#be185d' }, +]; + +export default function StatisticsDashboard() { + const navigate = useNavigate(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [patientStats, setPatientStats] = useState(null); + const [consultationStats, setConsultationStats] = useState(null); + const [followUpStats, setFollowUpStats] = useState(null); + const [pointsStats, setPointsStats] = useState(null); + + const fetchAllStats = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [patients, consultations, followUps, points] = await Promise.all([ + pointsApi.getPatientStats(), + pointsApi.getConsultationStats(), + pointsApi.getFollowUpStats(), + pointsApi.getStatistics(), + ]); + setPatientStats(patients); + setConsultationStats(consultations); + setFollowUpStats(followUps); + setPointsStats(points); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '加载统计数据失败'; + setError(message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAllStats(); + }, [fetchAllStats]); + + // ---- Derived stat cards ---- + const statCards: StatCardConfig[] = [ + { + title: '患者总数', + value: patientStats?.total_patients ?? 0, + prefix: , + subtitle: patientStats?.new_this_month ? `本月 +${patientStats.new_this_month}` : undefined, + color: '#2563eb', + bgColor: '#eff6ff', + }, + { + title: '咨询总量', + value: consultationStats?.total_sessions ?? 0, + prefix: , + subtitle: consultationStats?.this_month ? `本月 +${consultationStats.this_month}` : undefined, + color: '#7c3aed', + bgColor: '#f5f3ff', + }, + { + title: '随访完成率', + value: followUpStats?.completion_rate ?? 0, + suffix: '%', + precision: 1, + prefix: , + subtitle: followUpStats?.pending ? `待处理: ${followUpStats.pending}` : undefined, + color: '#059669', + bgColor: '#ecfdf5', + }, + { + title: '积分总发放', + value: pointsStats?.total_issued ?? 0, + prefix: , + subtitle: pointsStats?.active_accounts ? `活跃账户: ${pointsStats.active_accounts}` : undefined, + color: '#d97706', + bgColor: '#fffbeb', + }, + ]; + + // ---- Top earners table ---- + const topEarnerColumns = [ + { + title: '排名', + dataIndex: 'rank', + key: 'rank', + width: 70, + render: (rank: number) => { + const medalColors = ['#d97706', '#6b7280', '#b45309']; + const color = rank <= 3 ? medalColors[rank - 1] : undefined; + return ( + + {rank} + + ); + }, + }, + { + title: '患者 ID', + dataIndex: 'patient_id', + key: 'patient_id', + width: 180, + render: (id: string) => ( + + {id.length > 12 ? `${id.slice(0, 8)}...${id.slice(-4)}` : id} + + ), + }, + { + title: '累计积分', + dataIndex: 'total_earned', + key: 'total_earned', + width: 140, + render: (val: number) => {val.toLocaleString()}, + }, + ]; + + const topEarnerData: TopEarnerRow[] = (pointsStats?.top_earners ?? []).map((item, idx) => ({ + rank: idx + 1, + patient_id: item.patient_id, + total_earned: item.total_earned, + })); + + // ---- Loading / Error states ---- + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + } onClick={fetchAllStats}> + 重试 + + } + /> + ); + } + + return ( +
+ {/* Section 1: Top Stats Cards */} + + {statCards.map((card) => ( + + + {card.title} + } + value={card.value} + precision={card.precision} + suffix={card.suffix} + prefix={ + + {card.prefix} + + } + valueStyle={{ color: card.color, fontSize: 28, fontWeight: 700 }} + /> + {card.subtitle && ( +
+ + {card.subtitle} +
+ )} +
+ + ))} +
+ + {/* Section 2: Points Statistics Details */} + + + 积分统计 + + } + bordered={false} + style={{ borderRadius: 12 }} + extra={ + + } + > + + + } + /> + + + + + + + + + } + /> + + + + + 积分排行 Top 10 + + + + + {/* Section 3: Quick Links */} + + + 快捷入口 + + } + bordered={false} + style={{ borderRadius: 12 }} + > + + {QUICK_LINKS.map((link) => ( + + navigate(link.path)} + > +
+ {link.icon} +
+
+ {link.title} +
+
+ + ))} + + + + {/* Section 4: Recent Activity (top earners as proxy) */} + {topEarnerData.length > 0 && ( + + + 最近活动 + + } + bordered={false} + style={{ borderRadius: 12 }} + > +
+ + )} + + ); +}