diff --git a/apps/miniprogram/project.config.json b/apps/miniprogram/project.config.json index 37a9e18..0225de0 100644 --- a/apps/miniprogram/project.config.json +++ b/apps/miniprogram/project.config.json @@ -7,9 +7,9 @@ "automationAudits": true, "es6": false, "enhance": false, - "compileHotReLoad": true, + "compileHotReLoad": false, "postcss": false, - "minified": true, + "minified": false, "bundle": false, "minifyWXML": true, "packNpmManually": false, diff --git a/apps/miniprogram/project.private.config.json b/apps/miniprogram/project.private.config.json index 8cee929..936e419 100644 --- a/apps/miniprogram/project.private.config.json +++ b/apps/miniprogram/project.private.config.json @@ -13,7 +13,7 @@ "useStaticServer": false, "useLanDebug": false, "showES6CompileOption": false, - "compileHotReLoad": true, + "compileHotReLoad": false, "checkInvalidKey": true, "ignoreDevUnusedFiles": true, "bigPackageSizeSupport": false diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index c356b0c..2da8a0f 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -10,19 +10,15 @@ function App({ children }: PropsWithChildren>) { const restoreAuth = useAuthStore((s) => s.restore); const restoreUI = useUIStore((s) => s.restore); - // 首次 mount 时立即恢复认证状态(优先于 useDidShow) - useEffect(() => { - restoreAuth(); - restoreUI(); - }, []); - + // useDidShow 在首次 mount 时也会触发,不需要 useEffect 重复调用 useDidShow(() => { restoreAuth(); restoreUI(); }); - // 暴露全局 bridge 供 MCP/自动化测试调用 + // 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式) useEffect(() => { + if (process.env.NODE_ENV === 'production') return; (globalThis as any).__hms = { restoreAuth: () => { restoreAuth(); return useAuthStore.getState(); }, restoreUI, @@ -30,7 +26,7 @@ function App({ children }: PropsWithChildren>) { forceSetAuth: (state: Record) => useAuthStore.setState(state), }; return () => { delete (globalThis as any).__hms; }; - }, [restoreAuth, restoreUI]); + }, []); useEffect(() => { const timer = setInterval(() => { diff --git a/apps/miniprogram/src/components/EcCanvas/index.tsx b/apps/miniprogram/src/components/EcCanvas/index.tsx deleted file mode 100644 index 577c153..0000000 --- a/apps/miniprogram/src/components/EcCanvas/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; -import { Canvas, View } from '@tarojs/components'; -import Taro from '@tarojs/taro'; -import * as echarts from 'echarts/core'; -import { LineChart } from 'echarts/charts'; -import { - GridComponent, - TooltipComponent, - MarkAreaComponent, - MarkPointComponent, -} from 'echarts/components'; -import { CanvasRenderer } from 'echarts/renderers'; - -echarts.use([ - LineChart, - GridComponent, - TooltipComponent, - MarkAreaComponent, - MarkPointComponent, - CanvasRenderer, -]); - -interface EcCanvasProps { - canvasId: string; - height?: number; -} - -export interface EcCanvasRef { - setOption: (option: echarts.EChartsOption) => void; -} - -const EcCanvas = React.memo(React.forwardRef( - ({ canvasId, height = 300 }, ref) => { - const chartInstance = useRef(null); - const canvasNode = useRef(null); - - const initChart = async () => { - try { - const query = Taro.createSelectorQuery(); - query - .select(`#${canvasId}`) - .node() - .exec((res) => { - const node = res[0]?.node; - if (!node) return; - - canvasNode.current = node; - const dpr = Taro.getSystemInfoSync().pixelRatio; - const width = node.width || 350; - const heightVal = node.height || height; - - node.width = width * dpr; - node.height = heightVal * dpr; - - const ctx = node.getContext('2d'); - - chartInstance.current = echarts.init(ctx as any, undefined, { - renderer: 'canvas', - width, - height: heightVal, - devicePixelRatio: dpr, - }); - }); - } catch (e) { - console.error('EcCanvas init failed:', e); - } - }; - - useEffect(() => { - initChart(); - return () => { - chartInstance.current?.dispose(); - }; - }, []); - - useImperativeHandle(ref, () => ({ - setOption: (option: echarts.EChartsOption) => { - if (chartInstance.current) { - chartInstance.current.setOption(option); - } - }, - })); - - return ( - - - - ); - }, -)); - -EcCanvas.displayName = 'EcCanvas'; - -export default EcCanvas; diff --git a/apps/miniprogram/src/components/TrendChart/index.tsx b/apps/miniprogram/src/components/TrendChart/index.tsx index 3c16ee5..f94c8cf 100644 --- a/apps/miniprogram/src/components/TrendChart/index.tsx +++ b/apps/miniprogram/src/components/TrendChart/index.tsx @@ -11,7 +11,11 @@ interface TrendChartProps { height?: number; } -const DPR = Taro.getSystemInfoSync().pixelRatio || 2; +let _dpr = 0; +function getDPR(): number { + if (!_dpr) _dpr = Taro.getSystemInfoSync().pixelRatio || 2; + return _dpr; +} function drawLine( ctx: CanvasRenderingContext2D, @@ -42,22 +46,22 @@ export default React.memo(function TrendChart({ const node = canvasRef.current; if (!node || !data || data.length === 0) return; - const w = node.width / DPR; - const h = node.height / DPR; + const w = node.width / getDPR(); + const h = node.height / getDPR(); const ctx = node.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, node.width, node.height); ctx.save(); - ctx.scale(DPR, DPR); + ctx.scale(getDPR(), getDPR()); const pad = { left: 45, right: 15, top: 20, bottom: 30 }; const cw = w - pad.left - pad.right; const ch = h - pad.top - pad.bottom; const values = data.map((d) => d.value); - let yMin = Math.min(...values); - let yMax = Math.max(...values); + let yMin = values.reduce((a, b) => Math.min(a, b), Infinity); + let yMax = values.reduce((a, b) => Math.max(a, b), -Infinity); if (referenceMin != null) yMin = Math.min(yMin, referenceMin); if (referenceMax != null) yMax = Math.max(yMax, referenceMax); const yRange = yMax - yMin || 1; @@ -157,8 +161,8 @@ export default React.memo(function TrendChart({ canvasRef.current = node; const sysInfo = Taro.getSystemInfoSync(); const canvasW = (sysInfo.windowWidth * 750) / sysInfo.windowWidth; - node.width = sysInfo.windowWidth * DPR; - node.height = ((height / 750) * sysInfo.windowWidth) * DPR; + node.width = sysInfo.windowWidth * getDPR(); + node.height = ((height / 750) * sysInfo.windowWidth) * getDPR(); draw(); }); }, [draw, height]); diff --git a/apps/miniprogram/src/hooks/useThrottledDidShow.ts b/apps/miniprogram/src/hooks/useThrottledDidShow.ts new file mode 100644 index 0000000..231fea2 --- /dev/null +++ b/apps/miniprogram/src/hooks/useThrottledDidShow.ts @@ -0,0 +1,30 @@ +import { useRef, useCallback } from 'react'; +import { useDidShow } from '@tarojs/taro'; + +/** + * 带节流的 useDidShow — 距离上次执行不足 intervalMs 毫秒时跳过。 + * 返回手动强制刷新的 trigger 函数。 + */ +export function useThrottledDidShow( + fn: () => void, + intervalMs = 5000, +): { trigger: () => void } { + const lastRun = useRef(0); + const fnRef = useRef(fn); + fnRef.current = fn; + + useDidShow(() => { + const now = Date.now(); + if (now - lastRun.current >= intervalMs) { + lastRun.current = now; + fnRef.current(); + } + }); + + const trigger = useCallback(() => { + lastRun.current = Date.now(); + fnRef.current(); + }, []); + + return { trigger }; +} diff --git a/apps/miniprogram/src/pages/appointment/create/index.scss b/apps/miniprogram/src/pages/appointment/create/index.scss index ce0cc4a..09b1fa6 100644 --- a/apps/miniprogram/src/pages/appointment/create/index.scss +++ b/apps/miniprogram/src/pages/appointment/create/index.scss @@ -60,7 +60,7 @@ color: $pri; .dept-selected & { - color: white; + color: $white; } } @@ -254,8 +254,8 @@ } .doctor-check { - width: 44px; - height: 44px; + width: 48px; + height: 48px; border-radius: $r-pill; background: $pri; @include flex-center; @@ -264,7 +264,7 @@ .doctor-check-text { font-size: var(--tk-font-h2); - color: white; + color: $white; font-weight: bold; } @@ -345,5 +345,5 @@ } .btn-text-white { - color: white; + color: $white; } diff --git a/apps/miniprogram/src/pages/appointment/index.scss b/apps/miniprogram/src/pages/appointment/index.scss index 95fd628..171ba22 100644 --- a/apps/miniprogram/src/pages/appointment/index.scss +++ b/apps/miniprogram/src/pages/appointment/index.scss @@ -187,7 +187,7 @@ .fab-text { font-size: var(--tk-font-num); - color: white; + color: $white; font-weight: bold; letter-spacing: 2px; } diff --git a/apps/miniprogram/src/pages/appointment/index.tsx b/apps/miniprogram/src/pages/appointment/index.tsx index be1d975..c62b61b 100644 --- a/apps/miniprogram/src/pages/appointment/index.tsx +++ b/apps/miniprogram/src/pages/appointment/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listAppointments } from '../../services/appointment'; import type { Appointment } from '../../services/appointment'; import EmptyState from '../../components/EmptyState'; @@ -55,9 +56,9 @@ export default function AppointmentList() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchData(1, true); - }); + }, 10000); usePullDownRefresh(() => { fetchData(1, true).finally(() => { diff --git a/apps/miniprogram/src/pages/article/index.tsx b/apps/miniprogram/src/pages/article/index.tsx index a69af6e..fed7ac1 100644 --- a/apps/miniprogram/src/pages/article/index.tsx +++ b/apps/miniprogram/src/pages/article/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, Image, ScrollView } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listArticles, listCategories, Article, ArticleCategory } from '../../services/article'; import EmptyState from '../../components/EmptyState'; import Loading from '../../components/Loading'; @@ -48,9 +49,9 @@ export default function ArticleList() { fetchCategories(); }, [fetchCategories]); - useDidShow(() => { + useThrottledDidShow(() => { fetchData(1, false, null); - }); + }, 10000); usePullDownRefresh(() => { fetchData(1, false, null).finally(() => { diff --git a/apps/miniprogram/src/pages/consultation/detail/index.scss b/apps/miniprogram/src/pages/consultation/detail/index.scss index 21f6477..a5cbea6 100644 --- a/apps/miniprogram/src/pages/consultation/detail/index.scss +++ b/apps/miniprogram/src/pages/consultation/detail/index.scss @@ -134,6 +134,20 @@ } } +.msg-truncated-hint { + display: flex; + justify-content: center; + padding: 12px 0; + + &__text { + font-size: var(--tk-font-micro); + color: var(--tk-text-secondary); + background: $surface-alt; + padding: 2px 12px; + border-radius: $r-pill; + } +} + .msg-image { width: 200px; border-radius: $r-sm; @@ -175,7 +189,7 @@ .chat-input { flex: 1; - height: 40px; + height: 48px; background: $bg; border: 1.5px solid $bd; border-radius: $r-lg; @@ -185,8 +199,8 @@ } .chat-send-btn { - width: 40px; - height: 40px; + width: 48px; + height: 48px; border-radius: $r-lg; background: $pri; @include flex-center; diff --git a/apps/miniprogram/src/pages/consultation/detail/index.tsx b/apps/miniprogram/src/pages/consultation/detail/index.tsx index 65a6d60..b4ab3f7 100644 --- a/apps/miniprogram/src/pages/consultation/detail/index.tsx +++ b/apps/miniprogram/src/pages/consultation/detail/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, Input, Image, ScrollView } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; +import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'; import { getSession, listMessages, @@ -14,6 +14,15 @@ import Loading from '@/components/Loading'; import { useElderClass } from '@/hooks/useElderClass'; import './index.scss'; +/** DOM 节点数量上限,超过时只渲染最新的消息 */ +const MAX_RENDER_MESSAGES = 200; +/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */ +const MAX_STATE_MESSAGES = 300; +/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */ +const POLL_INTERVAL_MS = 3000; +/** 连续失败上限,超过后停止轮询 */ +const MAX_CONSECUTIVE_FAILURES = 50; + export default function ConsultationDetail() { const router = useRouter(); const sessionId = router.params.id || ''; @@ -24,6 +33,8 @@ export default function ConsultationDetail() { const [loading, setLoading] = useState(true); const scrollViewRef = useRef(''); const pollingRef = useRef(false); + const mountedRef = useRef(true); + const messagesRef = useRef([]); const modeClass = useElderClass(); useEffect(() => { @@ -32,9 +43,22 @@ export default function ConsultationDetail() { markRead(); startLongPolling(); } - return () => { pollingRef.current = false; }; + return () => { + pollingRef.current = false; + mountedRef.current = false; + }; }, [sessionId]); + // 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询) + useDidShow(() => { + if (sessionId && !pollingRef.current && session?.status !== 'closed') { + startLongPolling(); + } + }); + useDidHide(() => { + pollingRef.current = false; + }); + useEffect(() => { if (session?.status === 'closed') { pollingRef.current = false; @@ -46,24 +70,33 @@ export default function ConsultationDetail() { longPoll(); }; - const longPoll = async () => { - if (!pollingRef.current) return; + const longPoll = async (failCount = 0) => { + if (!pollingRef.current || !mountedRef.current) return; + if (failCount >= MAX_CONSECUTIVE_FAILURES) return; try { - const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined; + const currentMessages = messagesRef.current; + const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined; const newMsgs = await pollMessages(sessionId, lastId); + if (!mountedRef.current) return; if (newMsgs && newMsgs.length > 0) { setMessages((prev) => { const existing = new Set(prev.map((msg) => msg.id)); const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); - return [...prev, ...fresh]; + const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); + messagesRef.current = next; + return next; }); - scrollViewRef.current = `msg-${messages.length + newMsgs.length}`; + scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`; } + failCount = 0; } catch { - // 超时或网络错误,静默重试 + failCount++; } - if (pollingRef.current) { - longPoll(); + if (!pollingRef.current || !mountedRef.current) return; + const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS; + await new Promise((r) => setTimeout(r, delay)); + if (pollingRef.current && mountedRef.current) { + longPoll(failCount); } }; @@ -75,8 +108,10 @@ export default function ConsultationDetail() { listMessages(sessionId, { page: 1, page_size: 50 }), ]); setSession(s); - setMessages(m.data || []); - scrollViewRef.current = `msg-${(m.data || []).length}`; + const msgs = m.data || []; + setMessages(msgs); + messagesRef.current = msgs; + scrollViewRef.current = `msg-${msgs.length}`; if (s.status === 'closed') pollingRef.current = false; } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); @@ -98,8 +133,12 @@ export default function ConsultationDetail() { setInputText(''); try { const msg = await sendMessage(sessionId, text); - setMessages((prev) => [...prev, msg]); - scrollViewRef.current = `msg-${messages.length + 1}`; + setMessages((prev) => { + const next = [...prev, msg]; + messagesRef.current = next; + scrollViewRef.current = `msg-${next.length}`; + return next; + }); } catch { Taro.showToast({ title: '发送失败', icon: 'none' }); setInputText(text); @@ -131,6 +170,10 @@ export default function ConsultationDetail() { const isImageUrl = (url: string) => /\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(url); + // 渲染层面的消息数量上限,防止长对话 DOM 节点过多 + const hiddenCount = Math.max(0, messages.length - MAX_RENDER_MESSAGES); + const renderMessages = hiddenCount > 0 ? messages.slice(-MAX_RENDER_MESSAGES) : messages; + if (loading) return ; const isOpen = session?.status !== 'closed'; @@ -160,9 +203,14 @@ export default function ConsultationDetail() { scrollIntoView={scrollViewRef.current} scrollWithAnimation > - {messages.map((msg, idx) => { + {hiddenCount > 0 && ( + + 已隐藏较早的 {hiddenCount} 条消息 + + )} + {renderMessages.map((msg, idx) => { const isSelf = msg.sender_role === 'patient'; - const showDateDivider = idx === 0 || isDifferentDay(msg.created_at, messages[idx - 1].created_at); + const showDateDivider = idx === 0 || isDifferentDay(msg.created_at, renderMessages[idx - 1].created_at); return ( {showDateDivider && ( @@ -170,7 +218,7 @@ export default function ConsultationDetail() { {getDateLabel(msg.created_at)} )} - + {!isSelf && ( {doctorInitial} @@ -193,7 +241,7 @@ export default function ConsultationDetail() { ); })} - {messages.length === 0 && ( + {renderMessages.length === 0 && ( 暂无消息,发送第一条消息开始对话 diff --git a/apps/miniprogram/src/pages/consultation/index.tsx b/apps/miniprogram/src/pages/consultation/index.tsx index 13e4f1e..b38d01c 100644 --- a/apps/miniprogram/src/pages/consultation/index.tsx +++ b/apps/miniprogram/src/pages/consultation/index.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { useAuthStore } from '@/stores/auth'; import { listConsultations, ConsultationSession } from '@/services/consultation'; import Loading from '../../components/Loading'; @@ -69,11 +70,11 @@ export default function Consultation() { } }; - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '在线咨询' }); if (!user) return; loadSessions(1, true); - }); + }, 10000); usePullDownRefresh(() => { loadSessions(1, true).finally(() => { diff --git a/apps/miniprogram/src/pages/device-sync/index.tsx b/apps/miniprogram/src/pages/device-sync/index.tsx index 2604406..79121ef 100644 --- a/apps/miniprogram/src/pages/device-sync/index.tsx +++ b/apps/miniprogram/src/pages/device-sync/index.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useRouter } from '@tarojs/taro'; +import Taro, { useRouter } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { BLEManager } from '@/services/ble/BLEManager'; import { XiaomiBandAdapter } from '@/services/ble/adapters/XiaomiBandAdapter'; import { BloodPressureAdapter } from '@/services/ble/adapters/BloodPressureAdapter'; @@ -13,17 +14,14 @@ import type { BLEDevice, NormalizedReading } from '@/services/ble/types'; import { useElderClass } from '../../hooks/useElderClass'; import './index.scss'; -const bleManager = new BLEManager({ scanTimeout: 10000, retryCount: 3 }); -bleManager.registerAdapter(XiaomiBandAdapter); -bleManager.registerAdapter(BloodPressureAdapter); -bleManager.registerAdapter(GlucoseMeterAdapter); -bleManager.registerAdapter(CustomBandAdapter); +/** liveReadings 最大保留条数,防止内存无限增长 */ +const MAX_LIVE_READINGS = 200; type PageState = 'idle' | 'scanning' | 'connecting' | 'connected' | 'syncing' | 'done' | 'error'; export default function DeviceSync() { const modeClass = useElderClass(); - const { currentPatient } = useAuthStore(); + const currentPatient = useAuthStore((s) => s.currentPatient); const router = useRouter(); const returnTo = router.params.returnTo || ''; const [pageState, setPageState] = useState('idle'); @@ -39,10 +37,27 @@ export default function DeviceSync() { intervalMs: 60 * 60 * 1000, }), []); - useDidShow(() => { + const bleManagerRef = useRef(null); + const getBleManager = useCallback(() => { + if (!bleManagerRef.current) { + const mgr = new BLEManager({ scanTimeout: 10000, retryCount: 3 }); + mgr.registerAdapter(XiaomiBandAdapter); + mgr.registerAdapter(BloodPressureAdapter); + mgr.registerAdapter(GlucoseMeterAdapter); + mgr.registerAdapter(CustomBandAdapter); + bleManagerRef.current = mgr; + } + return bleManagerRef.current; + }, []); + + useThrottledDidShow(() => { + const bleManager = getBleManager(); bleManager.setOnConnectionChange(() => {}); bleManager.setOnReadings((readings) => { - setLiveReadings((prev) => [...prev, ...readings]); + setLiveReadings((prev) => { + const merged = [...prev, ...readings]; + return merged.length > MAX_LIVE_READINGS ? merged.slice(-MAX_LIVE_READINGS) : merged; + }); }); // 显示上次同步时间 @@ -65,19 +80,24 @@ export default function DeviceSync() { return { success: count > 0, uploadedCount: count }; }); } + }, 10000); + useEffect(() => { return () => { scheduler.destroy(); - bleManager.destroy(); + if (bleManagerRef.current) { + bleManagerRef.current.destroy(); + bleManagerRef.current = null; + } }; - }); + }, [scheduler]); const handleScan = useCallback(async () => { setPageState('scanning'); setDevices([]); setErrorMsg(''); try { - const found = await bleManager.scanDevices(); + const found = await getBleManager().scanDevices(); setDevices(found); if (found.length === 0) { setErrorMsg('未发现支持的设备,请确认设备已开启蓝牙并靠近手机'); @@ -94,7 +114,7 @@ export default function DeviceSync() { setPageState('connecting'); setErrorMsg(''); try { - await bleManager.connect(device); + await getBleManager().connect(device); setPageState('connected'); } catch (e: any) { setErrorMsg(e.message || '连接失败'); @@ -109,7 +129,7 @@ export default function DeviceSync() { setErrorMsg(''); try { - const result = await bleManager.syncToServer(async (readings) => { + const result = await getBleManager().syncToServer(async (readings) => { return uploadReadings( currentPatient.id, selectedDevice.deviceId, @@ -154,7 +174,7 @@ export default function DeviceSync() { }, [currentPatient, selectedDevice, liveReadings, returnTo]); const handleDisconnect = useCallback(async () => { - await bleManager.disconnect(); + await getBleManager().disconnect(); setPageState('idle'); setSelectedDevice(null); setLiveReadings([]); diff --git a/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx b/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx index 401131e..f4ff110 100644 --- a/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx +++ b/apps/miniprogram/src/pages/doctor/action-inbox/index.tsx @@ -1,6 +1,8 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; +import { api } from '@/services/request'; import { listActionItems, getActionThread, @@ -9,7 +11,6 @@ import { } from '@/services/action-inbox'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../hooks/useElderClass'; -import { getStatusInlineStyle, getStatusLabel } from '@/utils/statusTag'; import './index.scss'; const TYPE_LABEL: Record = { @@ -73,10 +74,10 @@ export default function ActionInboxPage() { [], ); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '待办事项' }); fetchItems(1, activeTab, true); - }); + }, 10000); usePullDownRefresh(() => { fetchItems(1, activeTab, true).then(() => @@ -105,12 +106,7 @@ export default function ActionInboxPage() { }) => { if (!action.api_endpoint || !threadData) return; try { - await Taro.request({ - url: `${process.env.TARO_APP_API_URL}${action.api_endpoint}`, - method: 'POST', - header: { 'Content-Type': 'application/json' }, - data: { action: action.key }, - }); + await api.post(action.api_endpoint, { action: action.key }); Taro.showToast({ title: '操作成功', icon: 'success' }); setShowDetail(false); fetchItems(1, activeTab, true); diff --git a/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx b/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx index 3f9441b..641c0a1 100644 --- a/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/alerts/detail/index.tsx @@ -1,7 +1,10 @@ import { useState, useEffect } from 'react'; import { View, Text, ScrollView, Button } from '@tarojs/components'; import Taro from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { + getAlert, acknowledgeAlert, dismissAlert, resolveAlert, + type Alert, +} from '@/services/doctor/alerts'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -22,7 +25,7 @@ const STATUS_MAP: Record = { export default function AlertDetail() { const modeClass = useElderClass(); - const [alert, setAlert] = useState(null); + const [alert, setAlert] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); @@ -35,14 +38,8 @@ export default function AlertDetail() { const loadAlert = async (id: string) => { try { - // 告警列表 API 支持按 ID 查询,此处用列表加载后过滤 - const res = await doctorApi.listAlerts({ page: 1, page_size: 100 }); - const found = (res.data || []).find((a) => a.id === id); - if (found) { - setAlert(found); - } else { - Taro.showToast({ title: '告警不存在', icon: 'none' }); - } + const data = await getAlert(id); + setAlert(data); } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); } finally { @@ -54,7 +51,7 @@ export default function AlertDetail() { if (!alert) return; setActionLoading(true); try { - const updated = await doctorApi.acknowledgeAlert(alert.id, alert.version); + const updated = await acknowledgeAlert(alert.id, alert.version); setAlert(updated); Taro.showToast({ title: '已确认', icon: 'success' }); } catch { @@ -68,7 +65,7 @@ export default function AlertDetail() { if (!alert) return; setActionLoading(true); try { - const updated = await doctorApi.dismissAlert(alert.id, alert.version); + const updated = await dismissAlert(alert.id, alert.version); setAlert(updated); Taro.showToast({ title: '已忽略', icon: 'success' }); } catch { @@ -82,7 +79,7 @@ export default function AlertDetail() { if (!alert) return; setActionLoading(true); try { - const updated = await doctorApi.resolveAlert(alert.id, alert.version); + const updated = await resolveAlert(alert.id, alert.version); setAlert(updated); Taro.showToast({ title: '已恢复', icon: 'success' }); } catch { @@ -132,7 +129,7 @@ export default function AlertDetail() { 患者 ID - {alert.patient_id.slice(0, 8)}... + {alert.patient_id ? `${alert.patient_id.slice(0, 8)}...` : '-'} diff --git a/apps/miniprogram/src/pages/doctor/alerts/index.tsx b/apps/miniprogram/src/pages/doctor/alerts/index.tsx index 58c0588..e68a17a 100644 --- a/apps/miniprogram/src/pages/doctor/alerts/index.tsx +++ b/apps/miniprogram/src/pages/doctor/alerts/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listAlerts, type Alert } from '@/services/doctor/alerts'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -30,7 +30,7 @@ const STATUS_TABS = [ export default function AlertList() { const modeClass = useElderClass(); - const [alerts, setAlerts] = useState([]); + const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(''); const [total, setTotal] = useState(0); @@ -45,7 +45,7 @@ export default function AlertList() { const loadAlerts = async () => { setLoading(true); try { - const res = await doctorApi.listAlerts({ + const res = await listAlerts({ status: activeTab || undefined, page, page_size: 20, @@ -64,7 +64,7 @@ export default function AlertList() { setPage(1); }; - const handleAlertClick = (alert: doctorApi.Alert) => { + const handleAlertClick = (alert: Alert) => { Taro.navigateTo({ url: `/pages/doctor/alerts/detail/index?id=${alert.id}` }); }; diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss b/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss index 551ea34..31915e5 100644 --- a/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.scss @@ -74,6 +74,20 @@ } } +.msg-truncated-hint { + display: flex; + justify-content: center; + padding: 12px 0; + + &__text { + font-size: var(--tk-font-body); + color: $tx3; + background: $bd-l; + padding: 4px 16px; + border-radius: $r; + } +} + .msg-time { @include serif-number; font-size: var(--tk-font-body); diff --git a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx index cf96f81..92aacff 100644 --- a/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/consultation/detail/index.tsx @@ -1,22 +1,37 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; -import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'; +import { + getSession, listMessages, pollMessages, + markSessionRead, sendMessage, closeSession, + type ConsultationSession, type ConsultationMessage, +} from '@/services/doctor/consultation'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; +/** DOM 节点数量上限,超过时只渲染最新的消息 */ +const MAX_RENDER_MESSAGES = 200; +/** React state 中保留的消息上限(比渲染上限略多,保留滚动缓冲) */ +const MAX_STATE_MESSAGES = 300; +/** 成功轮询后最小间隔(ms),防止后端快速响应时紧密递归 */ +const POLL_INTERVAL_MS = 3000; +/** 连续失败上限,超过后停止轮询 */ +const MAX_CONSECUTIVE_FAILURES = 50; + export default function ConsultationDetail() { const router = useRouter(); const sessionId = router.params.id || ''; const modeClass = useElderClass(); - const [session, setSession] = useState(null); - const [messages, setMessages] = useState([]); + const [session, setSession] = useState(null); + const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); const scrollViewRef = useRef(''); const pollingRef = useRef(false); + const mountedRef = useRef(true); + const messagesRef = useRef([]); useEffect(() => { if (sessionId) { @@ -24,9 +39,22 @@ export default function ConsultationDetail() { markRead(); startLongPolling(); } - return () => { pollingRef.current = false; }; + return () => { + pollingRef.current = false; + mountedRef.current = false; + }; }, [sessionId]); + // 页面可见时恢复轮询,不可见时暂停(防止 DevTools 后台页面累积轮询) + useDidShow(() => { + if (sessionId && !pollingRef.current && session?.status !== 'closed') { + startLongPolling(); + } + }); + useDidHide(() => { + pollingRef.current = false; + }); + useEffect(() => { if (session?.status === 'closed') { pollingRef.current = false; @@ -38,24 +66,33 @@ export default function ConsultationDetail() { longPoll(); }; - const longPoll = async () => { - if (!pollingRef.current) return; + const longPoll = async (failCount = 0) => { + if (!pollingRef.current || !mountedRef.current) return; + if (failCount >= MAX_CONSECUTIVE_FAILURES) return; try { - const lastId = messages.length > 0 ? messages[messages.length - 1].id : undefined; - const newMsgs = await doctorApi.pollMessages(sessionId, lastId); + const currentMessages = messagesRef.current; + const lastId = currentMessages.length > 0 ? currentMessages[currentMessages.length - 1].id : undefined; + const newMsgs = await pollMessages(sessionId, lastId); + if (!mountedRef.current) return; if (newMsgs && newMsgs.length > 0) { setMessages((prev) => { const existing = new Set(prev.map((msg) => msg.id)); const fresh = newMsgs.filter((msg) => !existing.has(msg.id)); - return [...prev, ...fresh]; + const next = [...prev, ...fresh].slice(-MAX_STATE_MESSAGES); + messagesRef.current = next; + return next; }); - scrollViewRef.current = `msg-${messages.length + newMsgs.length}`; + scrollViewRef.current = `msg-${currentMessages.length + newMsgs.length}`; } + failCount = 0; } catch { - // 超时或网络错误,静默重试 + failCount++; } - if (pollingRef.current) { - longPoll(); + if (!pollingRef.current || !mountedRef.current) return; + const delay = failCount > 0 ? Math.min(failCount * 2000, 30000) : POLL_INTERVAL_MS; + await new Promise((r) => setTimeout(r, delay)); + if (pollingRef.current && mountedRef.current) { + longPoll(failCount); } }; @@ -63,12 +100,14 @@ export default function ConsultationDetail() { setLoading(true); try { const [s, m] = await Promise.all([ - doctorApi.getSession(sessionId), - doctorApi.listMessages(sessionId, { page: 1, page_size: 50 }), + getSession(sessionId), + listMessages(sessionId, { page: 1, page_size: 50 }), ]); setSession(s); - setMessages(m.data || []); - scrollViewRef.current = `msg-${(m.data || []).length}`; + const msgs = m.data || []; + setMessages(msgs); + messagesRef.current = msgs; + scrollViewRef.current = `msg-${msgs.length}`; if (s.status === 'closed') pollingRef.current = false; } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); @@ -79,7 +118,7 @@ export default function ConsultationDetail() { const markRead = async () => { try { - await doctorApi.markSessionRead(sessionId); + await markSessionRead(sessionId); } catch { /* ignore */ } }; @@ -89,9 +128,13 @@ export default function ConsultationDetail() { setSending(true); setInputText(''); try { - const msg = await doctorApi.sendMessage(sessionId, text); - setMessages((prev) => [...prev, msg]); - scrollViewRef.current = `msg-${messages.length + 1}`; + const msg = await sendMessage(sessionId, text); + setMessages((prev) => { + const next = [...prev, msg]; + messagesRef.current = next; + scrollViewRef.current = `msg-${next.length}`; + return next; + }); } catch { Taro.showToast({ title: '发送失败', icon: 'none' }); setInputText(text); @@ -107,7 +150,7 @@ export default function ConsultationDetail() { success: async (res) => { if (res.confirm) { try { - await doctorApi.closeSession(sessionId, session?.version ?? 0); + await closeSession(sessionId, session?.version ?? 0); Taro.showToast({ title: '已关闭', icon: 'success' }); loadData(); } catch { @@ -123,6 +166,10 @@ export default function ConsultationDetail() { return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); }; + // 渲染层面的消息数量上限,防止长对话 DOM 节点过多 + const hiddenCount = Math.max(0, messages.length - MAX_RENDER_MESSAGES); + const renderMessages = hiddenCount > 0 ? messages.slice(-MAX_RENDER_MESSAGES) : messages; + if (loading) return ; const isOpen = session?.status !== 'closed'; @@ -144,10 +191,15 @@ export default function ConsultationDetail() { scrollIntoView={scrollViewRef.current} scrollWithAnimation > - {messages.map((msg, idx) => { + {hiddenCount > 0 && ( + + 已隐藏较早的 {hiddenCount} 条消息 + + )} + {renderMessages.map((msg, idx) => { const isDoctor = msg.sender_role === 'doctor'; return ( - + {msg.content} {formatTime(msg.created_at)} @@ -155,7 +207,7 @@ export default function ConsultationDetail() { ); })} - {messages.length === 0 && ( + {renderMessages.length === 0 && ( 暂无消息,发送第一条消息开始对话 diff --git a/apps/miniprogram/src/pages/doctor/consultation/index.tsx b/apps/miniprogram/src/pages/doctor/consultation/index.tsx index 07f2c33..e4e0111 100644 --- a/apps/miniprogram/src/pages/doctor/consultation/index.tsx +++ b/apps/miniprogram/src/pages/doctor/consultation/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listSessions, type ConsultationSession } from '@/services/doctor/consultation'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -18,7 +18,7 @@ const TABS = [ export default function ConsultationList() { const modeClass = useElderClass(); - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [activeTab, setActiveTab] = useState(''); const [loading, setLoading] = useState(true); const [total, setTotal] = useState(0); @@ -33,7 +33,7 @@ export default function ConsultationList() { const loadSessions = async () => { setLoading(true); try { - const res = await doctorApi.listSessions({ + const res = await listSessions({ page, page_size: 20, status: activeTab || undefined, diff --git a/apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx b/apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx index f6d8971..d66f4ed 100644 --- a/apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx +++ b/apps/miniprogram/src/pages/doctor/dialysis/create/index.tsx @@ -1,7 +1,9 @@ import { useState, useEffect } from 'react'; import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { + getDialysisRecord, updateDialysisRecord, createDialysisRecord, +} from '@/services/doctor/dialysis'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -69,7 +71,7 @@ export default function DialysisCreate() { const loadRecord = async () => { setLoading(true); try { - const r = await doctorApi.getDialysisRecord(id); + const r = await getDialysisRecord(id); setForm({ patient_id: r.patient_id, dialysis_date: r.dialysis_date || '', @@ -137,10 +139,10 @@ export default function DialysisCreate() { try { if (isEdit) { const { patient_id, ...updateData } = payload; - await doctorApi.updateDialysisRecord(id, updateData, version); + await updateDialysisRecord(id, updateData, version); Taro.showToast({ title: '更新成功', icon: 'success' }); } else { - await doctorApi.createDialysisRecord(payload); + await createDialysisRecord(payload); Taro.showToast({ title: '创建成功', icon: 'success' }); } setTimeout(() => Taro.navigateBack(), 1000); diff --git a/apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx b/apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx index 94d9ca8..8144a52 100644 --- a/apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/dialysis/detail/index.tsx @@ -1,7 +1,11 @@ import { useState, useEffect } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { + getDialysisRecord, reviewDialysisRecord, + updateDialysisRecord, deleteDialysisRecord, + type DialysisRecord, +} from '@/services/doctor/dialysis'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -10,7 +14,7 @@ export default function DialysisDetail() { const router = useRouter(); const id = router.params.id || ''; const modeClass = useElderClass(); - const [record, setRecord] = useState(null); + const [record, setRecord] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -21,7 +25,7 @@ export default function DialysisDetail() { const loadRecord = async () => { setLoading(true); try { - const r = await doctorApi.getDialysisRecord(id); + const r = await getDialysisRecord(id); setRecord(r); } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); @@ -34,7 +38,7 @@ export default function DialysisDetail() { if (!record) return; setSubmitting(true); try { - const updated = await doctorApi.reviewDialysisRecord(id, record.version); + const updated = await reviewDialysisRecord(id, record.version); setRecord(updated); Taro.showToast({ title: '审核完成', icon: 'success' }); } catch { @@ -48,7 +52,7 @@ export default function DialysisDetail() { if (!record) return; setSubmitting(true); try { - const updated = await doctorApi.updateDialysisRecord(id, { status: 'completed' }, record.version); + const updated = await updateDialysisRecord(id, { status: 'completed' }, record.version); setRecord(updated); Taro.showToast({ title: '已标记完成', icon: 'success' }); } catch { @@ -67,7 +71,7 @@ export default function DialysisDetail() { if (!confirm) return; setSubmitting(true); try { - await doctorApi.deleteDialysisRecord(id, record.version); + await deleteDialysisRecord(id, record.version); Taro.showToast({ title: '已删除', icon: 'success' }); setTimeout(() => Taro.navigateBack(), 1000); } catch { diff --git a/apps/miniprogram/src/pages/doctor/dialysis/index.tsx b/apps/miniprogram/src/pages/doctor/dialysis/index.tsx index dc292b7..107053c 100644 --- a/apps/miniprogram/src/pages/doctor/dialysis/index.tsx +++ b/apps/miniprogram/src/pages/doctor/dialysis/index.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listDialysisRecords, type DialysisRecord } from '@/services/doctor/dialysis'; +import { listPatients } from '@/services/doctor/patient'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -23,7 +24,7 @@ export default function DialysisList() { const [searchPatient, setSearchPatient] = useState(''); const [currentPatientId, setCurrentPatientId] = useState(patientId); const [activeTab, setActiveTab] = useState(''); - const [records, setRecords] = useState([]); + const [records, setRecords] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -35,7 +36,9 @@ export default function DialysisList() { const loadRecords = async (p: number) => { setLoading(true); try { - const res = await doctorApi.listDialysisRecords(currentPatientId, { page: p, page_size: 20 }); + const params: { page: number; page_size: number; status?: string } = { page: p, page_size: 20 }; + if (activeTab) params.status = activeTab; + const res = await listDialysisRecords(currentPatientId, params); setRecords(res.data || []); setTotal(res.total || 0); setPage(p); @@ -50,7 +53,7 @@ export default function DialysisList() { if (!searchPatient.trim()) return; setLoading(true); try { - const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); + const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); if (res.data && res.data.length > 0) { setCurrentPatientId(res.data[0].id); Taro.setNavigationBarTitle({ title: res.data[0].name + '的透析记录' }); @@ -69,7 +72,7 @@ export default function DialysisList() { setPage(1); }; - const filtered = activeTab ? records.filter((r) => r.status === activeTab) : records; + // 服务端已按 activeTab 过滤,无需客户端二次筛选 if (loading && records.length === 0) return ; @@ -102,12 +105,12 @@ export default function DialysisList() { {!currentPatientId ? ( - ) : filtered.length === 0 ? ( + ) : records.length === 0 ? ( ) : ( 共 {total} 条记录 - {filtered.map((r) => ( + {records.map((r) => ( (null); - const [records, setRecords] = useState([]); + const [task, setTask] = useState(null); + const [records, setRecords] = useState([]); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -37,8 +41,8 @@ export default function FollowUpDetail() { setLoading(true); try { const [t, r] = await Promise.all([ - doctorApi.getFollowUpTask(taskId), - doctorApi.listFollowUpRecords({ task_id: taskId }), + getFollowUpTask(taskId), + listFollowUpRecords({ task_id: taskId }), ]); setTask(t); setRecords(r.data || []); @@ -56,7 +60,7 @@ export default function FollowUpDetail() { } setSubmitting(true); try { - await doctorApi.createFollowUpRecord(taskId, { + await createFollowUpRecord(taskId, { result: result.trim(), patient_condition: patientCondition.trim() || undefined, medical_advice: medicalAdvice.trim() || undefined, @@ -78,7 +82,7 @@ export default function FollowUpDetail() { const handleStartTask = async () => { if (!task) return; try { - await doctorApi.updateFollowUpTask(taskId, { status: 'in_progress' }, task.version); + await updateFollowUpTask(taskId, { status: 'in_progress' }, task.version); Taro.showToast({ title: '已开始', icon: 'success' }); loadData(); } catch { @@ -180,12 +184,11 @@ export default function FollowUpDetail() { 下次随访日期 - setNextDate(e.target.value)} - /> + setNextDate(e.detail.value)}> + + {nextDate || '请选择日期'} + + {submitting ? '提交中...' : '提交记录'} diff --git a/apps/miniprogram/src/pages/doctor/followup/index.tsx b/apps/miniprogram/src/pages/doctor/followup/index.tsx index 5048e12..fc42c28 100644 --- a/apps/miniprogram/src/pages/doctor/followup/index.tsx +++ b/apps/miniprogram/src/pages/doctor/followup/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listFollowUpTasks, type FollowUpTask } from '@/services/doctor/followup'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -20,7 +20,7 @@ export default function FollowUpList() { const router = useRouter(); const patientId = router.params.patientId || ''; const modeClass = useElderClass(); - const [tasks, setTasks] = useState([]); + const [tasks, setTasks] = useState([]); const [activeTab, setActiveTab] = useState(''); const [loading, setLoading] = useState(true); const [total, setTotal] = useState(0); @@ -32,7 +32,7 @@ export default function FollowUpList() { const loadTasks = async () => { setLoading(true); try { - const res = await doctorApi.listFollowUpTasks({ + const res = await listFollowUpTasks({ page: 1, page_size: 50, status: activeTab || undefined, diff --git a/apps/miniprogram/src/pages/doctor/index.tsx b/apps/miniprogram/src/pages/doctor/index.tsx index e3ef55c..194cd4b 100644 --- a/apps/miniprogram/src/pages/doctor/index.tsx +++ b/apps/miniprogram/src/pages/doctor/index.tsx @@ -3,12 +3,13 @@ import { View, Text, Input, ScrollView } from '@tarojs/components'; import Taro from '@tarojs/taro'; import { useAuthStore } from '@/stores/auth'; import { useElderClass } from '../../hooks/useElderClass'; -import * as doctorApi from '@/services/doctor'; +import { useThrottledDidShow } from '../../hooks/useThrottledDidShow'; +import { getDashboard, type DoctorDashboard } from '@/services/doctor/dashboard'; import Loading from '@/components/Loading'; import './index.scss'; interface CardConfig { - key: keyof doctorApi.DoctorDashboard; + key: keyof DoctorDashboard; label: string; initial: string; route: string; @@ -53,9 +54,11 @@ const ROLE_LABELS: Record = { }; export default function DoctorHome() { - const { user, logout, roles } = useAuthStore(); + const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); + const roles = useAuthStore((s) => s.roles); const modeClass = useElderClass(); - const [dashboard, setDashboard] = useState(null); + const [dashboard, setDashboard] = useState(null); const [alertCount, setAlertCount] = useState(0); const [loading, setLoading] = useState(true); @@ -77,9 +80,13 @@ export default function DoctorHome() { loadDashboard(); }, []); + useThrottledDidShow(() => { + loadDashboard(); + }, 10000); + const loadDashboard = async () => { try { - const data = await doctorApi.getDashboard(); + const data = await getDashboard(); setDashboard(data); // 从仪表盘数据提取异常体征患者数 const count = (data as Record)?.abnormal_vital_count; @@ -99,7 +106,7 @@ export default function DoctorHome() { logout(); }; - const getValue = (key: keyof doctorApi.DoctorDashboard): number | string => { + const getValue = (key: keyof DoctorDashboard): number | string => { if (!dashboard) return '-'; return dashboard[key] ?? 0; }; diff --git a/apps/miniprogram/src/pages/doctor/patients/detail/index.tsx b/apps/miniprogram/src/pages/doctor/patients/detail/index.tsx index b4d3565..a986947 100644 --- a/apps/miniprogram/src/pages/doctor/patients/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/patients/detail/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { getPatient, getHealthSummary, type PatientDetail, type HealthSummary } from '@/services/doctor/patient'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -10,8 +10,8 @@ export default function PatientDetail() { const router = useRouter(); const patientId = router.params.id || ''; const modeClass = useElderClass(); - const [patient, setPatient] = useState(null); - const [summary, setSummary] = useState(null); + const [patient, setPatient] = useState(null); + const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -22,8 +22,8 @@ export default function PatientDetail() { setLoading(true); try { const [p, s] = await Promise.all([ - doctorApi.getPatient(patientId), - doctorApi.getHealthSummary(patientId), + getPatient(patientId), + getHealthSummary(patientId), ]); setPatient(p); setSummary(s); diff --git a/apps/miniprogram/src/pages/doctor/patients/index.tsx b/apps/miniprogram/src/pages/doctor/patients/index.tsx index 002bd9a..7dbbeb6 100644 --- a/apps/miniprogram/src/pages/doctor/patients/index.tsx +++ b/apps/miniprogram/src/pages/doctor/patients/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listPatients, listPatientTags, type PatientItem, type PatientTag } from '@/services/doctor/patient'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -9,8 +9,8 @@ import './index.scss'; export default function PatientList() { const modeClass = useElderClass(); - const [patients, setPatients] = useState([]); - const [tags, setTags] = useState([]); + const [patients, setPatients] = useState([]); + const [tags, setTags] = useState([]); const [activeTag, setActiveTag] = useState(''); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(true); @@ -28,7 +28,7 @@ export default function PatientList() { const loadTags = async () => { try { - const res = await doctorApi.listPatientTags(); + const res = await listPatientTags(); setTags(res.data || []); } catch { /* ignore */ } }; @@ -38,7 +38,7 @@ export default function PatientList() { loadingRef.current = true; if (isRefresh) setLoading(true); try { - const res = await doctorApi.listPatients({ + const res = await listPatients({ page: pageNum, page_size: 20, search: search || undefined, diff --git a/apps/miniprogram/src/pages/doctor/prescription/create/index.tsx b/apps/miniprogram/src/pages/doctor/prescription/create/index.tsx index 24f24e3..2a57adf 100644 --- a/apps/miniprogram/src/pages/doctor/prescription/create/index.tsx +++ b/apps/miniprogram/src/pages/doctor/prescription/create/index.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { View, Text, Input, Textarea, Picker, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { createDialysisPrescription } from '@/services/doctor/dialysis'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -90,7 +90,7 @@ export default function PrescriptionCreate() { }; try { - await doctorApi.createDialysisPrescription(payload); + await createDialysisPrescription(payload); Taro.showToast({ title: '创建成功', icon: 'success' }); setTimeout(() => Taro.navigateBack(), 1000); } catch { diff --git a/apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx b/apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx index d61a85a..955f738 100644 --- a/apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/prescription/detail/index.tsx @@ -1,7 +1,10 @@ import { useState, useEffect } from 'react'; import { View, Text, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { + getDialysisPrescription, updateDialysisPrescription, deleteDialysisPrescription, + type DialysisPrescription, +} from '@/services/doctor/dialysis'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -10,7 +13,7 @@ export default function PrescriptionDetail() { const router = useRouter(); const id = router.params.id || ''; const modeClass = useElderClass(); - const [rx, setRx] = useState(null); + const [rx, setRx] = useState(null); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -21,7 +24,7 @@ export default function PrescriptionDetail() { const loadRx = async () => { setLoading(true); try { - const data = await doctorApi.getDialysisPrescription(id); + const data = await getDialysisPrescription(id); setRx(data); } catch { Taro.showToast({ title: '加载失败', icon: 'none' }); @@ -39,7 +42,7 @@ export default function PrescriptionDetail() { if (!confirm) return; setSubmitting(true); try { - const updated = await doctorApi.updateDialysisPrescription(id, { status: 'inactive' }, rx.version); + const updated = await updateDialysisPrescription(id, { status: 'inactive' }, rx.version); setRx(updated); Taro.showToast({ title: '已停用', icon: 'success' }); } catch { @@ -58,7 +61,7 @@ export default function PrescriptionDetail() { if (!confirm) return; setSubmitting(true); try { - await doctorApi.deleteDialysisPrescription(id, rx.version); + await deleteDialysisPrescription(id, rx.version); Taro.showToast({ title: '已删除', icon: 'success' }); setTimeout(() => Taro.navigateBack(), 1000); } catch { diff --git a/apps/miniprogram/src/pages/doctor/prescription/index.tsx b/apps/miniprogram/src/pages/doctor/prescription/index.tsx index 36fb6f3..0cb8cd6 100644 --- a/apps/miniprogram/src/pages/doctor/prescription/index.tsx +++ b/apps/miniprogram/src/pages/doctor/prescription/index.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listDialysisPrescriptions, type DialysisPrescription } from '@/services/doctor/dialysis'; +import { listPatients } from '@/services/doctor/patient'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -20,7 +21,7 @@ export default function PrescriptionList() { const [searchPatient, setSearchPatient] = useState(''); const [currentPatientId, setCurrentPatientId] = useState(patientId); const [activeTab, setActiveTab] = useState(''); - const [prescriptions, setPrescriptions] = useState([]); + const [prescriptions, setPrescriptions] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -32,7 +33,7 @@ export default function PrescriptionList() { const loadData = async (p: number) => { setLoading(true); try { - const res = await doctorApi.listDialysisPrescriptions({ + const res = await listDialysisPrescriptions({ patient_id: currentPatientId || undefined, status: activeTab || undefined, page: p, @@ -52,7 +53,7 @@ export default function PrescriptionList() { if (!searchPatient.trim()) return; setLoading(true); try { - const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); + const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); if (res.data && res.data.length > 0) { setCurrentPatientId(res.data[0].id); } else { diff --git a/apps/miniprogram/src/pages/doctor/report/detail/index.tsx b/apps/miniprogram/src/pages/doctor/report/detail/index.tsx index eedab5e..ce9427f 100644 --- a/apps/miniprogram/src/pages/doctor/report/detail/index.tsx +++ b/apps/miniprogram/src/pages/doctor/report/detail/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import { View, Text, Textarea, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { getLabReport, reviewLabReport, type LabReportDetail } from '@/services/doctor/labReport'; import Loading from '@/components/Loading'; import { useElderClass } from '../../../../hooks/useElderClass'; import './index.scss'; @@ -11,7 +11,7 @@ export default function ReportDetail() { const patientId = router.params.patientId || ''; const reportId = router.params.id || ''; const modeClass = useElderClass(); - const [report, setReport] = useState(null); + const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [doctorNotes, setDoctorNotes] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -23,7 +23,7 @@ export default function ReportDetail() { const loadReport = async () => { setLoading(true); try { - const r = await doctorApi.getLabReport(patientId, reportId); + const r = await getLabReport(patientId, reportId); setReport(r); setDoctorNotes(r.doctor_notes || ''); } catch { @@ -37,7 +37,7 @@ export default function ReportDetail() { if (!report) return; setSubmitting(true); try { - const updated = await doctorApi.reviewLabReport(patientId, reportId, { + const updated = await reviewLabReport(patientId, reportId, { doctor_notes: doctorNotes.trim() || undefined, version: report.version, }); diff --git a/apps/miniprogram/src/pages/doctor/report/index.tsx b/apps/miniprogram/src/pages/doctor/report/index.tsx index 2a561c1..5c60647 100644 --- a/apps/miniprogram/src/pages/doctor/report/index.tsx +++ b/apps/miniprogram/src/pages/doctor/report/index.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from 'react'; import { View, Text, Input, ScrollView } from '@tarojs/components'; import Taro, { useRouter } from '@tarojs/taro'; -import * as doctorApi from '@/services/doctor'; +import { listLabReports, type LabReportItem } from '@/services/doctor/labReport'; +import { listPatients } from '@/services/doctor/patient'; import Loading from '@/components/Loading'; import EmptyState from '@/components/EmptyState'; import { useElderClass } from '../../../hooks/useElderClass'; @@ -13,7 +14,7 @@ export default function ReportList() { const modeClass = useElderClass(); const [searchPatient, setSearchPatient] = useState(''); const [currentPatientId, setCurrentPatientId] = useState(patientId); - const [reports, setReports] = useState([]); + const [reports, setReports] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); @@ -24,7 +25,7 @@ export default function ReportList() { const loadReports = async () => { setLoading(true); try { - const res = await doctorApi.listLabReports(currentPatientId, { page: 1, page_size: 50 }); + const res = await listLabReports(currentPatientId, { page: 1, page_size: 50 }); setReports(res.data || []); setTotal(res.total || 0); } catch { @@ -38,7 +39,7 @@ export default function ReportList() { if (!searchPatient.trim()) return; setLoading(true); try { - const res = await doctorApi.listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); + const res = await listPatients({ search: searchPatient.trim(), page: 1, page_size: 1 }); if (res.data && res.data.length > 0) { setCurrentPatientId(res.data[0].id); Taro.setNavigationBarTitle({ title: res.data[0].name + '的化验报告' }); diff --git a/apps/miniprogram/src/pages/health/index.scss b/apps/miniprogram/src/pages/health/index.scss index 6419986..0811592 100644 --- a/apps/miniprogram/src/pages/health/index.scss +++ b/apps/miniprogram/src/pages/health/index.scss @@ -29,7 +29,7 @@ .vital-tab { flex: 1; - height: 40px; + height: 48px; border-radius: $r-sm; background: $surface-alt; @include flex-center; @@ -273,8 +273,8 @@ } .device-icon { - width: 44px; - height: 44px; + width: 48px; + height: 48px; border-radius: $r-sm; background: $pri-l; @include flex-center; diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index c909b20..731a82b 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import { View, Text, Input } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { usePullDownRefresh } from '@tarojs/taro'; import { useHealthStore } from '../../stores/health'; import { useAuthStore } from '../../stores/auth'; import { useElderClass } from '../../hooks/useElderClass'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { inputVitalSign, getTrend, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../services/health'; import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis'; import Loading from '../../components/Loading'; @@ -41,8 +42,12 @@ interface TrendPoint { } export default function Health() { - const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore(); - const { user, currentPatient } = useAuthStore(); + const todaySummary = useHealthStore((s) => s.todaySummary); + const loading = useHealthStore((s) => s.loading); + const refreshToday = useHealthStore((s) => s.refreshToday); + const fetchTrend = useHealthStore((s) => s.getTrend); + const user = useAuthStore((s) => s.user); + const currentPatient = useAuthStore((s) => s.currentPatient); const modeClass = useElderClass(); const [activeTab, setActiveTab] = useState('blood_pressure'); const [systolic, setSystolic] = useState(''); @@ -57,13 +62,16 @@ export default function Health() { const [aiSuggestions, setAiSuggestions] = useState([]); const [thresholds, setThresholds] = useState(DEFAULT_THRESHOLDS); - useDidShow(() => { + useThrottledDidShow(() => { if (!user) return; - refreshToday(); - loadTrend(activeTab); - loadAiSuggestions(); - getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }); - }); + // 批量发起请求,避免串行 setState 级联重渲染 + Promise.allSettled([ + refreshToday(), + loadTrend(activeTab), + loadAiSuggestions(), + getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }), + ]); + }, 5000); usePullDownRefresh(() => { if (!user) return; @@ -202,7 +210,7 @@ export default function Health() { } }; - const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1); + const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1); const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => { if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140; @@ -228,7 +236,7 @@ export default function Health() { } else if (first?.suggestion_type === 'followup') { Taro.navigateTo({ url: '/pages/pkg-profile/followups/index' }); } else { - Taro.navigateTo({ url: '/pages/health/index' }); + Taro.switchTab({ url: '/pages/health/index' }); } }}> diff --git a/apps/miniprogram/src/pages/index/index.scss b/apps/miniprogram/src/pages/index/index.scss index 2faeda8..604056f 100644 --- a/apps/miniprogram/src/pages/index/index.scss +++ b/apps/miniprogram/src/pages/index/index.scss @@ -40,8 +40,8 @@ .greeting-bell { position: relative; - width: 44px; - height: 44px; + width: 48px; + height: 48px; border-radius: $r-pill; background: $pri-l; @include flex-center; @@ -340,28 +340,13 @@ background: linear-gradient(135deg, $pri-d 0%, $pri 60%, $pri-l 100%); } &--2 { - background: linear-gradient(135deg, $acc 0%, #3D5A40 60%, $acc-l 100%); + background: linear-gradient(135deg, $acc 0%, $acc-d 60%, $acc-l 100%); } &--3 { - background: linear-gradient(135deg, #8B6F4E 0%, $wrn 60%, $wrn-l 100%); + background: linear-gradient(135deg, $wrn-d 0%, $wrn 60%, $wrn-l 100%); } } -.guest-slide-image { - position: absolute; - inset: 0; - width: 100%; - height: 100%; -} - -.guest-slide:nth-child(2) .guest-slide-bg { - background: linear-gradient(135deg, $acc 0%, #3D5A40 60%, $acc-l 100%); -} - -.guest-slide:nth-child(3) .guest-slide-bg { - background: linear-gradient(135deg, #8B6F4E 0%, $wrn 60%, $wrn-l 100%); -} - .guest-slide-content { position: relative; z-index: 1; diff --git a/apps/miniprogram/src/pages/index/index.tsx b/apps/miniprogram/src/pages/index/index.tsx index 6443de7..a765c21 100644 --- a/apps/miniprogram/src/pages/index/index.tsx +++ b/apps/miniprogram/src/pages/index/index.tsx @@ -1,12 +1,13 @@ import { View, Text, Swiper, SwiperItem, Image } from '@tarojs/components'; -import { useState } from 'react'; -import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import { useState, useMemo } from 'react'; +import Taro, { usePullDownRefresh, useDidShow, useDidHide } from '@tarojs/taro'; import { useAuthStore } from '../../stores/auth'; import { useUIStore } from '../../stores/ui'; import { navigateToLogin } from '../../utils/navigate'; import { useHealthStore } from '../../stores/health'; import ProgressRing from '../../components/ProgressRing'; import Loading from '../../components/Loading'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { trackPageView } from '@/services/analytics'; import * as appointmentApi from '@/services/appointment'; import * as followupApi from '@/services/followup'; @@ -30,8 +31,6 @@ interface PublicBanner { image_url?: string; link_type?: string; link_target?: string; - /** 下载后的本地临时路径 */ - local_path?: string; } // ─── 访客首页 ─── @@ -45,10 +44,14 @@ const FALLBACK_SLIDES = [ function GuestHome({ modeClass }: { modeClass: string }) { const [banners, setBanners] = useState([]); const [articles, setArticles] = useState([]); + const [swiperAutoplay, setSwiperAutoplay] = useState(false); - useDidShow(() => { + useDidShow(() => { setSwiperAutoplay(true); }); + useDidHide(() => { setSwiperAutoplay(false); }); + + useThrottledDidShow(() => { loadPublicData(); - }); + }, 10_000); const loadPublicData = async () => { let tenantId = Taro.getStorageSync('tenant_id'); @@ -70,20 +73,12 @@ function GuestHome({ modeClass }: { modeClass: string }) { if (bannerData.status === 'fulfilled' && bannerData.value?.length > 0) { const apiBase = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; - const withLocal = await Promise.all( - bannerData.value.map(async (b) => { - if (!b.image_url) return b; - try { - const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${apiBase}${b.image_url}`; - const res = await Taro.downloadFile({ url: fullUrl }); - if (res.tempFilePath) { - return { ...b, local_path: res.tempFilePath }; - } - } catch { /* ignore */ } - return b; - }) - ); - setBanners(withLocal); + const resolved = bannerData.value.map((b) => { + if (!b.image_url) return b; + const fullUrl = b.image_url.startsWith('http') ? b.image_url : `${apiBase}${b.image_url}`; + return { ...b, image_url: fullUrl }; + }); + setBanners(resolved); } else { setBanners(FALLBACK_SLIDES); } @@ -107,7 +102,7 @@ function GuestHome({ modeClass }: { modeClass: string }) { indicatorDots indicatorColor='rgba(255,255,255,0.4)' indicatorActiveColor='#FFFFFF' - autoplay + autoplay={swiperAutoplay} circular interval={4000} duration={500} @@ -115,8 +110,8 @@ function GuestHome({ modeClass }: { modeClass: string }) { {slides.map((slide, idx) => ( - {(slide.local_path || slide.image_url) ? ( - + {(slide.image_url) ? ( + ) : ( )} @@ -187,18 +182,21 @@ function GuestHome({ modeClass }: { modeClass: string }) { // ─── 登录后首页 ─── function HomeDashboard({ modeClass }: { modeClass: string }) { - const { user, currentPatient } = useAuthStore(); - const { todaySummary, loading, refreshToday } = useHealthStore(); + const user = useAuthStore((s) => s.user); + const currentPatient = useAuthStore((s) => s.currentPatient); + const todaySummary = useHealthStore((s) => s.todaySummary); + const loading = useHealthStore((s) => s.loading); + const refreshToday = useHealthStore((s) => s.refreshToday); const [reminders, setReminders] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [remindersLoading, setRemindersLoading] = useState(false); - useDidShow(() => { + const { trigger: triggerHomeRefresh } = useThrottledDidShow(() => { refreshToday(); loadReminders(); loadUnread(); trackPageView('home'); - }); + }, 5000); usePullDownRefresh(() => { Promise.all([refreshToday(true), loadReminders(), loadUnread()]).finally(() => { @@ -272,19 +270,19 @@ function HomeDashboard({ modeClass }: { modeClass: string }) { const completedCount = indicators.filter(Boolean).length; const progressPercent = Math.round((completedCount / 4) * 100); - const indicatorCapsules = [ + const indicatorCapsules = useMemo(() => [ { label: '血压', done: !!summary.blood_pressure }, { label: '心率', done: !!summary.heart_rate }, { label: '血糖', done: !!summary.blood_sugar }, { label: '体重', done: !!summary.weight }, - ]; + ], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]); - const healthItems = [ + const healthItems = useMemo(() => [ { label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' }, { label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' }, { label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' }, { label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' }, - ]; + ], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]); const getStatusTag = (status?: string) => { if (status === 'high' || status === 'low') return { label: status === 'high' ? '偏高' : '偏低', cls: 'tag-warn' }; diff --git a/apps/miniprogram/src/pages/login/index.scss b/apps/miniprogram/src/pages/login/index.scss index c46da77..92b22bd 100644 --- a/apps/miniprogram/src/pages/login/index.scss +++ b/apps/miniprogram/src/pages/login/index.scss @@ -91,6 +91,12 @@ border: none; } + &--dev { + margin-top: 16px; + background: $wrn; + box-shadow: 0 4px 16px rgba($wrn, 0.2); + } + &:active { opacity: 0.85; } diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index ddce300..6f35a15 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -5,18 +5,23 @@ import { useAuthStore } from '../../stores/auth'; import { useElderClass } from '../../hooks/useElderClass'; import './index.scss'; +const IS_DEV = process.env.NODE_ENV !== 'production'; + export default function Login() { const modeClass = useElderClass(); const [needBind, setNeedBind] = useState(false); const [agreed, setAgreed] = useState(false); - const { login, bindPhone, loading, isMedicalStaff } = useAuthStore(); + const login = useAuthStore((s) => s.login); + const bindPhone = useAuthStore((s) => s.bindPhone); + const loading = useAuthStore((s) => s.loading); + const isMedicalStaff = useAuthStore((s) => s.isMedicalStaff); // 登录页不应用关怀模式(正常模式尺寸已足够大) const loginClass = ''; const navigateAfterLogin = () => { if (isMedicalStaff()) { - Taro.redirectTo({ url: '/pages/doctor/index' }); + Taro.reLaunch({ url: '/pages/doctor/index' }); } else { Taro.switchTab({ url: '/pages/index/index' }); } @@ -42,6 +47,19 @@ export default function Login() { } }; + /** Dev 模式快速登录:跳过 getPhoneNumber,用 mock 数据直接调用绑定 API */ + const handleDevQuickLogin = async () => { + try { + const success = await bindPhone('dev_mock_encrypted', 'dev_mock_iv'); + if (success) { + navigateAfterLogin(); + } + } catch (err: any) { + Taro.showToast({ title: err?.message || '绑定失败', icon: 'none' }); + setNeedBind(false); + } + }; + const handleGetPhone = async (e: { detail: { errMsg: string; encryptedData: string; iv: string } }) => { if (!agreed) { Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' }); @@ -97,14 +115,21 @@ export default function Login() { 微信一键登录 ) : ( - + <> + + {IS_DEV && ( + + )} + )} diff --git a/apps/miniprogram/src/pages/mall/index.tsx b/apps/miniprogram/src/pages/mall/index.tsx index 7dfefbc..ecc3a90 100644 --- a/apps/miniprogram/src/pages/mall/index.tsx +++ b/apps/miniprogram/src/pages/mall/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listProducts } from '../../services/points'; import type { PointsProduct } from '../../services/points'; import { useAuthStore } from '../../stores/auth'; @@ -23,8 +24,12 @@ const TYPE_BG: Record = { }; export default function Mall() { - const { currentPatient, loadPatients } = useAuthStore(); - const { account, checkinStatus, refresh: refreshPoints, doCheckin } = usePointsStore(); + const currentPatient = useAuthStore((s) => s.currentPatient); + const loadPatients = useAuthStore((s) => s.loadPatients); + const account = usePointsStore((s) => s.account); + const checkinStatus = usePointsStore((s) => s.checkinStatus); + const refreshPoints = usePointsStore((s) => s.refresh); + const doCheckin = usePointsStore((s) => s.doCheckin); const [products, setProducts] = useState([]); const [productType, setProductType] = useState(''); const [page, setPage] = useState(1); @@ -82,10 +87,10 @@ export default function Mall() { [currentPatient, loadPatients, refreshPoints, fetchProducts, productType], ); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '积分商城' }); loadAll(); - }); + }, 10000); usePullDownRefresh(() => { loadAll().finally(() => { diff --git a/apps/miniprogram/src/pages/messages/index.scss b/apps/miniprogram/src/pages/messages/index.scss index 10971cf..de5fadc 100644 --- a/apps/miniprogram/src/pages/messages/index.scss +++ b/apps/miniprogram/src/pages/messages/index.scss @@ -32,7 +32,7 @@ .msg-segment-tab { flex: 1; - height: 40px; + height: 48px; border-radius: $r-xs; @include flex-center; position: relative; @@ -119,8 +119,8 @@ } .consult-avatar { - width: 44px; - height: 44px; + width: 48px; + height: 48px; border-radius: $r-pill; background: $surface-alt; @include flex-center; diff --git a/apps/miniprogram/src/pages/messages/index.tsx b/apps/miniprogram/src/pages/messages/index.tsx index 2713de7..a7c10ef 100644 --- a/apps/miniprogram/src/pages/messages/index.tsx +++ b/apps/miniprogram/src/pages/messages/index.tsx @@ -1,12 +1,13 @@ import { useState, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'; +import Taro, { useReachBottom } from '@tarojs/taro'; import { listConsultations, ConsultationSession } from '../../services/consultation'; import { notificationService } from '../../services/notification'; import Loading from '../../components/Loading'; import GuestGuard from '../../components/GuestGuard'; import { useAuthStore } from '../../stores/auth'; import { useElderClass } from '../../hooks/useElderClass'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import './index.scss'; type MsgTab = 'consultation' | 'notification'; @@ -76,9 +77,9 @@ export default function Messages() { } }; - useDidShow(() => { + useThrottledDidShow(() => { if (user) loadData(activeTab, 1, true); - }); + }, 5000); const handleTabChange = (tab: MsgTab) => { setActiveTab(tab); diff --git a/apps/miniprogram/src/pages/pkg-health/alerts/index.tsx b/apps/miniprogram/src/pages/pkg-health/alerts/index.tsx index 1d11eea..fe1a19c 100644 --- a/apps/miniprogram/src/pages/pkg-health/alerts/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/alerts/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listPatientAlerts, type Alert } from '@/services/alert'; import { useAuthStore } from '@/stores/auth'; import Loading from '@/components/Loading'; @@ -23,7 +24,7 @@ const STATUS_TABS = [ export default function PatientAlerts() { const modeClass = useElderClass(); - const { currentPatient } = useAuthStore(); + const currentPatient = useAuthStore((s) => s.currentPatient); const [alerts, setAlerts] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); @@ -60,10 +61,10 @@ export default function PatientAlerts() { [currentPatient], ); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '健康告警' }); fetchAlerts(1, status, true); - }); + }, 10000); usePullDownRefresh(() => { fetchAlerts(1, status, true).finally(() => Taro.stopPullDownRefresh()); diff --git a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.scss b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.scss index 9f9540c..e982f54 100644 --- a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.scss @@ -270,7 +270,7 @@ .dm-submit-text { font-size: var(--tk-font-num); - color: white; + color: $white; font-weight: bold; letter-spacing: 2px; } diff --git a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx index b31441a..cb6d1e9 100644 --- a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx @@ -60,7 +60,7 @@ const FIELD_LABELS: Record = { export default function DailyMonitoring() { const modeClass = useElderClass(); - const { currentPatient } = useAuthStore(); + const currentPatient = useAuthStore((s) => s.currentPatient); const today = formatDate(new Date()); const [dateIdx, setDateIdx] = useState(0); diff --git a/apps/miniprogram/src/pages/pkg-health/input/index.scss b/apps/miniprogram/src/pages/pkg-health/input/index.scss index 260aab1..90869b2 100644 --- a/apps/miniprogram/src/pages/pkg-health/input/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/input/index.scss @@ -226,7 +226,7 @@ .input-submit-text { font-size: var(--tk-font-num); - color: white; + color: $white; font-weight: bold; letter-spacing: 2px; } diff --git a/apps/miniprogram/src/pages/pkg-health/input/index.tsx b/apps/miniprogram/src/pages/pkg-health/input/index.tsx index e508149..ac0ee57 100644 --- a/apps/miniprogram/src/pages/pkg-health/input/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/input/index.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { View, Text, Input, Picker } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { num, validateStr } from '@/utils/validate'; import { inputVitalSign, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../../services/health'; import { useAuthStore } from '../../../stores/auth'; @@ -9,6 +10,7 @@ import { usePointsStore } from '@/stores/points'; import { clearRequestCache } from '@/services/request'; import { trackEvent } from '@/services/analytics'; import { useElderClass } from '../../../hooks/useElderClass'; +import Loading from '../../../components/Loading'; import './index.scss'; const INDICATORS = [ @@ -59,12 +61,14 @@ export default function HealthInput() { const [diastolic, setDiastolic] = useState(''); const [note, setNote] = useState(''); const [submitting, setSubmitting] = useState(false); - const { currentPatient } = useAuthStore(); - const { clearCache } = useHealthStore(); + const [loadingThresholds, setLoadingThresholds] = useState(true); + const currentPatient = useAuthStore((s) => s.currentPatient); + const clearCache = useHealthStore((s) => s.clearCache); /** 从 storage 中读取设备同步回传的数据并自动填充表单 */ - useDidShow(() => { - getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }); + useThrottledDidShow(() => { + setLoadingThresholds(true); + getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }).finally(() => setLoadingThresholds(false)); try { const raw = Taro.getStorageSync('device_sync_result'); if (!raw) return; @@ -88,7 +92,7 @@ export default function HealthInput() { } catch { // 解析失败则忽略,不影响正常使用 } - }); + }, 10000); const handleSubmit = async () => { if (!currentPatient) { @@ -164,6 +168,10 @@ export default function HealthInput() { return ( + {loadingThresholds && } + + {!loadingThresholds && ( + <> {/* 页面标题 */} @@ -267,6 +275,8 @@ export default function HealthInput() { > {submitting ? '提交中...' : '提交录入'} + + )} ); } diff --git a/apps/miniprogram/src/pages/pkg-health/trend/index.scss b/apps/miniprogram/src/pages/pkg-health/trend/index.scss index 11890ea..bceb74f 100644 --- a/apps/miniprogram/src/pages/pkg-health/trend/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/trend/index.scss @@ -65,7 +65,7 @@ } .trange-tab-text-active { - color: white; + color: $white; } /* ── chart card ── */ diff --git a/apps/miniprogram/src/pages/pkg-health/trend/index.tsx b/apps/miniprogram/src/pages/pkg-health/trend/index.tsx index a2551f9..988b5cf 100644 --- a/apps/miniprogram/src/pages/pkg-health/trend/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/trend/index.tsx @@ -31,7 +31,7 @@ export default function Trend() { const [range, setRange] = useState('7d'); const [points, setPoints] = useState<{ date: string; value: number }[]>([]); const [loading, setLoading] = useState(true); - const { getTrend } = useHealthStore(); + const getTrend = useHealthStore((s) => s.getTrend); useEffect(() => { setLoading(true); diff --git a/apps/miniprogram/src/pages/pkg-mall/detail/index.tsx b/apps/miniprogram/src/pages/pkg-mall/detail/index.tsx index d632bc3..85c52a9 100644 --- a/apps/miniprogram/src/pages/pkg-mall/detail/index.tsx +++ b/apps/miniprogram/src/pages/pkg-mall/detail/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listMyTransactions } from '../../../services/points'; import type { PointsTransaction } from '../../../services/points'; import { usePointsStore } from '../../../stores/points'; @@ -17,7 +18,8 @@ const TYPE_TABS = [ export default function PointsDetail() { const modeClass = useElderClass(); - const { account, refresh: refreshPoints } = usePointsStore(); + const account = usePointsStore((s) => s.account); + const refreshPoints = usePointsStore((s) => s.refresh); const [transactions, setTransactions] = useState([]); const [activeTab, setActiveTab] = useState(''); const [page, setPage] = useState(1); @@ -64,10 +66,10 @@ export default function PointsDetail() { [refreshPoints, fetchTransactions, activeTab], ); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '积分明细' }); loadAll(); - }); + }, 10000); usePullDownRefresh(() => { loadAll().finally(() => { diff --git a/apps/miniprogram/src/pages/pkg-mall/exchange/index.scss b/apps/miniprogram/src/pages/pkg-mall/exchange/index.scss index 3e195a5..d3c81dc 100644 --- a/apps/miniprogram/src/pages/pkg-mall/exchange/index.scss +++ b/apps/miniprogram/src/pages/pkg-mall/exchange/index.scss @@ -196,6 +196,6 @@ .confirm-btn-text { font-size: var(--tk-font-num); - color: white; + color: $white; font-weight: bold; } diff --git a/apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx b/apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx index 6ba2c2f..54ac68b 100644 --- a/apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx +++ b/apps/miniprogram/src/pages/pkg-mall/exchange/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listProducts, exchangeProduct, @@ -32,14 +33,15 @@ const TYPE_CLASS: Record = { export default function ExchangeConfirm() { const modeClass = useElderClass(); const [product, setProduct] = useState(null); - const { account, refresh: refreshPoints } = usePointsStore(); + const account = usePointsStore((s) => s.account); + const refreshPoints = usePointsStore((s) => s.refresh); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '确认兑换' }); loadData(); - }); + }, 10000); const loadData = useCallback(async () => { const instance = Taro.getCurrentInstance(); @@ -101,7 +103,7 @@ export default function ExchangeConfirm() { showCancel: false, confirmText: '查看订单', success: () => { - Taro.navigateTo({ + Taro.redirectTo({ url: `/pages/pkg-mall/orders/index`, }); }, diff --git a/apps/miniprogram/src/pages/pkg-mall/orders/index.tsx b/apps/miniprogram/src/pages/pkg-mall/orders/index.tsx index 1af4c5f..6398b8e 100644 --- a/apps/miniprogram/src/pages/pkg-mall/orders/index.tsx +++ b/apps/miniprogram/src/pages/pkg-mall/orders/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import Taro, { useReachBottom, usePullDownRefresh } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listMyOrders } from '../../../services/points'; import type { PointsOrder } from '../../../services/points'; import EmptyState from '../../../components/EmptyState'; @@ -70,10 +71,10 @@ export default function MallOrders() { [fetchOrders, activeTab], ); - useDidShow(() => { + useThrottledDidShow(() => { Taro.setNavigationBarTitle({ title: '我的订单' }); loadAll(); - }); + }, 10000); usePullDownRefresh(() => { loadAll().finally(() => { @@ -133,7 +134,7 @@ export default function MallOrders() { text='暂无订单' hint='去商城兑换心仪商品吧' actionText='去商城' - onAction={() => Taro.redirectTo({ url: '/pages/mall/index' })} + onAction={() => Taro.switchTab({ url: '/pages/mall/index' })} /> ) : ( diff --git a/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx b/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx index 22da0b2..c238e87 100644 --- a/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/consents/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listConsents, revokeConsent } from '@/services/consent'; import type { Consent } from '@/services/consent'; import EmptyState from '@/components/EmptyState'; @@ -29,13 +30,16 @@ export default function ConsentList() { const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [revoking, setRevoking] = useState(null); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setConsents([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listConsents(patientId, { page: p, page_size: 20 }); @@ -50,7 +54,7 @@ export default function ConsentList() { } }, []); - useDidShow(() => { fetchData(1); }); + useThrottledDidShow(() => { fetchData(1); }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => Taro.stopPullDownRefresh()); @@ -118,7 +122,7 @@ export default function ConsentList() { {consents.length === 0 && !loading && ( - + )} {loading && } diff --git a/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx b/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx index 7147e3d..f3df781 100644 --- a/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/diagnoses/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listDiagnoses, Diagnosis } from '../../../services/health-record'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; @@ -25,13 +26,16 @@ export default function Diagnoses() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setRecords([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listDiagnoses(patientId, { page: p, page_size: 20 }); @@ -46,9 +50,9 @@ export default function Diagnoses() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchData(1); - }); + }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => { @@ -94,7 +98,7 @@ export default function Diagnoses() { {records.length === 0 && !loading && ( - + )} {loading && } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx index 6d23b86..09c174c 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-prescriptions/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listDialysisPrescriptions } from '@/services/dialysis'; import type { DialysisPrescription } from '@/services/dialysis'; import EmptyState from '@/components/EmptyState'; @@ -20,13 +21,16 @@ export default function DialysisPrescriptionList() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setPrescriptions([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listDialysisPrescriptions({ patient_id: patientId, page: p, page_size: 20 }); @@ -41,7 +45,7 @@ export default function DialysisPrescriptionList() { } }, []); - useDidShow(() => { fetchData(1); }); + useThrottledDidShow(() => { fetchData(1); }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => Taro.stopPullDownRefresh()); @@ -91,7 +95,7 @@ export default function DialysisPrescriptionList() { {prescriptions.length === 0 && !loading && ( - + )} {loading && } diff --git a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx index 802e9c4..ab63be1 100644 --- a/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/dialysis-records/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listDialysisRecords } from '@/services/dialysis'; import type { DialysisRecord } from '@/services/dialysis'; import EmptyState from '@/components/EmptyState'; @@ -26,13 +27,16 @@ export default function DialysisRecordList() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setRecords([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listDialysisRecords(patientId, { page: p, page_size: 20 }); @@ -47,7 +51,7 @@ export default function DialysisRecordList() { } }, []); - useDidShow(() => { fetchData(1); }); + useThrottledDidShow(() => { fetchData(1); }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => Taro.stopPullDownRefresh()); @@ -96,7 +100,7 @@ export default function DialysisRecordList() { {records.length === 0 && !loading && ( - + )} {loading && } diff --git a/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx b/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx index f12e98c..0499826 100644 --- a/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/family-add/index.tsx @@ -34,6 +34,7 @@ export default function FamilyAdd() { return; } setSubmitting(true); + Taro.showLoading({ title: '提交中...' }); try { if (editId && editData) { await updatePatient(editId, { @@ -42,6 +43,7 @@ export default function FamilyAdd() { birth_date: birthDate || undefined, relation: RELATION_OPTIONS[relationIdx], }, editData.version); + Taro.hideLoading(); Taro.showToast({ title: '修改成功', icon: 'success' }); } else { await createPatient({ @@ -49,10 +51,12 @@ export default function FamilyAdd() { gender: GENDER_OPTIONS[genderIdx] === '男' ? 'male' : 'female', birth_date: birthDate || undefined, }); + Taro.hideLoading(); Taro.showToast({ title: '添加成功', icon: 'success' }); } setTimeout(() => Taro.navigateBack(), 1000); } catch { + Taro.hideLoading(); Taro.showToast({ title: editId ? '修改失败' : '添加失败', icon: 'none' }); } finally { setSubmitting(false); diff --git a/apps/miniprogram/src/pages/pkg-profile/family/index.tsx b/apps/miniprogram/src/pages/pkg-profile/family/index.tsx index 8edf1b8..c1b2dce 100644 --- a/apps/miniprogram/src/pages/pkg-profile/family/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/family/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listPatients, Patient } from '../../../services/patient'; import { useAuthStore } from '../../../stores/auth'; import EmptyState from '../../../components/EmptyState'; @@ -11,7 +12,8 @@ export default function FamilyList() { const modeClass = useElderClass(); const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(false); - const { currentPatient, setCurrentPatient } = useAuthStore(); + const currentPatient = useAuthStore((s) => s.currentPatient); + const setCurrentPatient = useAuthStore((s) => s.setCurrentPatient); const fetchPatients = useCallback(async () => { setLoading(true); @@ -25,9 +27,9 @@ export default function FamilyList() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchPatients(); - }); + }, 10000); const handleSelect = (patient: Patient) => { setCurrentPatient({ diff --git a/apps/miniprogram/src/pages/pkg-profile/followups/index.tsx b/apps/miniprogram/src/pages/pkg-profile/followups/index.tsx index 16a2998..d11ad35 100644 --- a/apps/miniprogram/src/pages/pkg-profile/followups/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/followups/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listTasks, FollowUpTask } from '../../../services/followup'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; @@ -31,9 +32,9 @@ export default function MyFollowUps() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchTasks(activeTab); - }); + }, 10000); const handleTabChange = (key: string) => { setActiveTab(key); diff --git a/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx b/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx index b831113..ced8004 100644 --- a/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/health-records/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listHealthRecords, HealthRecord } from '../../../services/health-record'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; @@ -19,13 +20,16 @@ export default function HealthRecords() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setRecords([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listHealthRecords(patientId, { page: p, page_size: 20 }); @@ -40,9 +44,9 @@ export default function HealthRecords() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchData(1); - }); + }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => { @@ -83,7 +87,7 @@ export default function HealthRecords() { {records.length === 0 && !loading && ( - + )} {loading && } diff --git a/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx b/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx index 990c2b5..641f953 100644 --- a/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/reports/index.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow, usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import Taro, { usePullDownRefresh, useReachBottom } from '@tarojs/taro'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; import { listReports, LabReport } from '../../../services/report'; import EmptyState from '../../../components/EmptyState'; import Loading from '../../../components/Loading'; @@ -13,13 +14,16 @@ export default function MyReports() { const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [hasPatient, setHasPatient] = useState(true); const fetchData = useCallback(async (p: number, append = false) => { const patientId = Taro.getStorageSync('current_patient_id') || ''; if (!patientId) { setReports([]); + setHasPatient(false); return; } + setHasPatient(true); setLoading(true); try { const res = await listReports(patientId, p); @@ -34,9 +38,9 @@ export default function MyReports() { } }, []); - useDidShow(() => { + useThrottledDidShow(() => { fetchData(1); - }); + }, 10000); usePullDownRefresh(() => { fetchData(1).finally(() => { @@ -97,7 +101,7 @@ export default function MyReports() { {reports.length === 0 && !loading && ( - + )} {loading && ( diff --git a/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx b/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx index d1c35a4..823e4ba 100644 --- a/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx +++ b/apps/miniprogram/src/pages/pkg-profile/settings/index.tsx @@ -7,30 +7,35 @@ import './index.scss'; export default function Settings() { const modeClass = useElderClass(); - const { logout } = useAuthStore(); + const logout = useAuthStore((s) => s.logout); - const handleClearCache = () => { - Taro.showModal({ + const handleClearCache = async () => { + const { confirm } = await Taro.showModal({ title: '清除缓存', content: '确定要清除本地缓存数据吗?不会影响账号信息。', - }).then((res) => { - if (res.confirm) { - const preservedKeys = ['access_token', 'refresh_token', 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id']; - const preservedData: Record = {}; - for (const key of preservedKeys) { - const val = Taro.getStorageSync(key); - if (val) preservedData[key] = val; - } - - Taro.clearStorageSync(); - - for (const [key, val] of Object.entries(preservedData)) { - Taro.setStorageSync(key, val); - } - - Taro.showToast({ title: '缓存已清除', icon: 'success' }); - } }); + if (!confirm) return; + + const preservedKeys = ['access_token', 'refresh_token', 'user_data', 'user_roles', 'tenant_id', 'wechat_openid', 'current_patient', 'current_patient_id']; + const preserved: Record = {}; + await Promise.all( + preservedKeys.map(async (key) => { + try { + const val = await Taro.getStorage({ key }); + if (val.data) preserved[key] = val.data; + } catch { /* key not found */ } + }), + ); + + await Taro.clearStorage(); + + await Promise.all( + Object.entries(preserved).map(([key, val]) => + Taro.setStorage({ key, data: val }), + ), + ); + + Taro.showToast({ title: '缓存已清除', icon: 'success' }); }; const handleAbout = () => { diff --git a/apps/miniprogram/src/pages/profile/index.tsx b/apps/miniprogram/src/pages/profile/index.tsx index 21a3396..0a61b38 100644 --- a/apps/miniprogram/src/pages/profile/index.tsx +++ b/apps/miniprogram/src/pages/profile/index.tsx @@ -1,9 +1,12 @@ import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro from '@tarojs/taro'; +import { useState } from 'react'; import { useAuthStore } from '../../stores/auth'; import { usePointsStore } from '../../stores/points'; import { useUIStore } from '../../stores/ui'; import { navigateToLogin } from '../../utils/navigate'; +import { useThrottledDidShow } from '@/hooks/useThrottledDidShow'; +import Loading from '../../components/Loading'; import './index.scss'; interface MenuItem { @@ -76,16 +79,23 @@ const GUEST_GROUPS: MenuGroup[] = [ ]; export default function Profile() { - const { user, logout } = useAuthStore(); - const { account: pointsAccount, checkinStatus: checkinInfo, refresh: refreshPoints } = usePointsStore(); + const user = useAuthStore((s) => s.user); + const logout = useAuthStore((s) => s.logout); + const pointsAccount = usePointsStore((s) => s.account); + const checkinInfo = usePointsStore((s) => s.checkinStatus); + const refreshPoints = usePointsStore((s) => s.refresh); const mode = useUIStore((s) => s.mode); const modeClass = mode === 'elder' ? 'elder-mode' : ''; const isGuest = !user; const groups = isGuest ? GUEST_GROUPS : LOGGED_IN_GROUPS; + const [pointsLoading, setPointsLoading] = useState(false); - useDidShow(() => { - if (!isGuest) refreshPoints(); - }); + useThrottledDidShow(() => { + if (!isGuest) { + setPointsLoading(true); + refreshPoints().finally(() => setPointsLoading(false)); + } + }, 5000); const handleMenuClick = (item: MenuItem) => { if (item.isSwitchTab) { @@ -139,6 +149,9 @@ export default function Profile() { {/* 积分 + 打卡 */} + {pointsLoading ? ( + + ) : ( {(pointsAccount?.balance ?? 0).toLocaleString()} @@ -149,6 +162,7 @@ export default function Profile() { 连续打卡 + )} )} diff --git a/apps/miniprogram/src/pages/report/detail/index.tsx b/apps/miniprogram/src/pages/report/detail/index.tsx index 3082edb..23b35c8 100644 --- a/apps/miniprogram/src/pages/report/detail/index.tsx +++ b/apps/miniprogram/src/pages/report/detail/index.tsx @@ -4,6 +4,7 @@ import Taro, { useRouter } from '@tarojs/taro'; import { getReportDetail, LabReport } from '../../../services/report'; import Loading from '../../../components/Loading'; import { useElderClass } from '../../../hooks/useElderClass'; +import { useAuthStore } from '../../../stores/auth'; import './index.scss'; interface IndicatorItem { @@ -19,7 +20,8 @@ export default function ReportDetail() { const modeClass = useElderClass(); const router = useRouter(); const id = router.params.id || ''; - const patientId = Taro.getStorageSync('current_patient_id') || ''; + const currentPatient = useAuthStore((s) => s.currentPatient); + const patientId = currentPatient?.id || ''; const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); diff --git a/apps/miniprogram/src/services/analytics.ts b/apps/miniprogram/src/services/analytics.ts index 7cf7b67..8d60f2f 100644 --- a/apps/miniprogram/src/services/analytics.ts +++ b/apps/miniprogram/src/services/analytics.ts @@ -1,6 +1,5 @@ import Taro from '@tarojs/taro'; import { api } from './request'; -import { secureGet } from '@/utils/secure-storage'; type EventName = | 'page_view' @@ -26,36 +25,46 @@ interface AnalyticsEvent { patientId?: string; } -const QUEUE_KEY = 'analytics_queue'; +const STORAGE_KEY = 'analytics_queue'; const MAX_QUEUE_SIZE = 50; -function getQueue(): AnalyticsEvent[] { - return Taro.getStorageSync(QUEUE_KEY) || []; +let memoryQueue: AnalyticsEvent[] = []; +let queueLoaded = false; +let persistTimer: ReturnType | null = null; + +function loadQueue(): void { + if (queueLoaded) return; + try { + const raw = Taro.getStorageSync(STORAGE_KEY); + if (raw) memoryQueue = (raw as AnalyticsEvent[]).slice(-MAX_QUEUE_SIZE); + } catch { /* ignore */ } + queueLoaded = true; } -function setQueue(queue: AnalyticsEvent[]): void { - Taro.setStorageSync(QUEUE_KEY, queue.slice(-MAX_QUEUE_SIZE)); +function persistQueue(): void { + try { + Taro.setStorage({ key: STORAGE_KEY, data: memoryQueue.slice(-MAX_QUEUE_SIZE) }); + } catch { /* ignore */ } } export function trackEvent(event: EventName | string, properties?: Record): void { - let userId: string | undefined; - try { - const raw = secureGet('user_data'); - userId = raw ? JSON.parse(raw).id : undefined; - } catch { /* ignore */ } - const patientId = Taro.getStorageSync('current_patient_id'); - + loadQueue(); const evt: AnalyticsEvent = { event, properties, timestamp: Date.now(), - userId, - patientId, }; - - const queue = getQueue(); - queue.push(evt); - setQueue(queue); + memoryQueue.push(evt); + if (memoryQueue.length > MAX_QUEUE_SIZE) { + memoryQueue = memoryQueue.slice(-MAX_QUEUE_SIZE); + } + // 防抖写入:3 秒内合并多次 trackEvent 为一次 Storage 写 + if (!persistTimer) { + persistTimer = setTimeout(() => { + persistTimer = null; + persistQueue(); + }, 3000); + } } export function trackPageView(pageName: string, properties?: Record): void { @@ -63,21 +72,24 @@ export function trackPageView(pageName: string, properties?: Record { - const queue = getQueue(); - if (queue.length === 0) return; + loadQueue(); + if (memoryQueue.length === 0) return; - const batch = queue.slice(); - setQueue([]); + const batch = memoryQueue.slice(); + memoryQueue = []; + persistQueue(); try { await api.post('/analytics/batch', { events: batch }); - } catch { - // 发送失败,回填队列 - const current = getQueue(); - setQueue([...batch.slice(-MAX_QUEUE_SIZE + current.length), ...current]); + } catch (e) { + // 静默失败,不打印错误避免控制台洪泛 + void e; + memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE); + persistQueue(); } } export function getQueueSize(): number { - return getQueue().length; + loadQueue(); + return memoryQueue.length; } diff --git a/apps/miniprogram/src/services/ble/BLEManager.ts b/apps/miniprogram/src/services/ble/BLEManager.ts index ba70433..01c0faa 100644 --- a/apps/miniprogram/src/services/ble/BLEManager.ts +++ b/apps/miniprogram/src/services/ble/BLEManager.ts @@ -25,6 +25,8 @@ export class BLEManager { private scanTimer: ReturnType | null = null; private onConnectionChange?: (state: BLEConnectionState) => void; private onReadings?: (readings: NormalizedReading[]) => void; + private connChangeHandler: ((res: any) => void) | null = null; + private charChangeHandler: ((res: any) => void) | null = null; constructor(config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; @@ -143,13 +145,22 @@ export class BLEManager { timeout: 10000, }); + // 移除旧监听器,避免多次 connect 累积 + if (this.connChangeHandler) { + Taro.offBLEConnectionStateChange(this.connChangeHandler); + } + if (this.charChangeHandler) { + Taro.offBLECharacteristicValueChange(this.charChangeHandler); + } + // 监听断连 - Taro.onBLEConnectionStateChange((res: any) => { + this.connChangeHandler = (res: any) => { if (res.deviceId === device.deviceId && !res.connected) { this.updateState('disconnected', '设备断开连接'); this.connection = null; } - }); + }; + Taro.onBLEConnectionStateChange(this.connChangeHandler); // 发现服务 const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId }); @@ -174,7 +185,7 @@ export class BLEManager { } // 监听数据通知 - Taro.onBLECharacteristicValueChange((res: any) => { + this.charChangeHandler = (res: any) => { if (res.deviceId !== device.deviceId) return; const newReadings = device.adapter!.parseNotification( res.serviceId, @@ -186,7 +197,8 @@ export class BLEManager { this.dataBuffer.push(newReadings); this.onReadings?.(newReadings); } - }); + }; + Taro.onBLECharacteristicValueChange(this.charChangeHandler); this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() }; this.updateState('connected'); @@ -273,6 +285,17 @@ export class BLEManager { if (!this.connection) return; const { deviceId } = this.connection; + + // 移除 BLE 监听器,防止断开后仍收到回调 + if (this.connChangeHandler) { + Taro.offBLEConnectionStateChange(this.connChangeHandler); + this.connChangeHandler = null; + } + if (this.charChangeHandler) { + Taro.offBLECharacteristicValueChange(this.charChangeHandler); + this.charChangeHandler = null; + } + try { await Taro.closeBLEConnection({ deviceId }); } catch { @@ -326,5 +349,3 @@ export class BLEManager { } } } - -export default new BLEManager(); diff --git a/apps/miniprogram/src/services/ble/DataBuffer.ts b/apps/miniprogram/src/services/ble/DataBuffer.ts index 73dd736..6be8ba9 100644 --- a/apps/miniprogram/src/services/ble/DataBuffer.ts +++ b/apps/miniprogram/src/services/ble/DataBuffer.ts @@ -16,6 +16,8 @@ const DEFAULT_CONFIG: Required = { storageKeyPrefix: 'ble_buffer', }; +const MAX_BUCKETS = 20; + /** 离线数据缓冲 — 分桶持久化到 Storage,支持去重和容量管理 */ export class DataBuffer { private config: Required; @@ -76,9 +78,14 @@ export class DataBuffer { let total = 0; let idx = 0; - while (true) { + while (idx < MAX_BUCKETS) { const key = `${this.config.storageKeyPrefix}_${idx}`; - const raw = Taro.getStorageSync(key) as string; + let raw: string; + try { + raw = Taro.getStorageSync(key) as string; + } catch { + break; + } if (!raw) break; try { const parsed: NormalizedReading[] = JSON.parse(raw); @@ -138,9 +145,14 @@ export class DataBuffer { private clearStorage(): void { let idx = 0; - while (true) { + while (idx < MAX_BUCKETS) { const key = `${this.config.storageKeyPrefix}_${idx}`; - const raw = Taro.getStorageSync(key) as string; + let raw: string; + try { + raw = Taro.getStorageSync(key) as string; + } catch { + break; + } if (!raw) break; try { Taro.removeStorageSync(key); } catch { /* ignore */ } idx++; diff --git a/apps/miniprogram/src/services/doctor/alerts.ts b/apps/miniprogram/src/services/doctor/alerts.ts index 5e11303..e02eb47 100644 --- a/apps/miniprogram/src/services/doctor/alerts.ts +++ b/apps/miniprogram/src/services/doctor/alerts.ts @@ -26,6 +26,10 @@ export async function listAlerts(params?: { return api.get<{ data: Alert[]; total: number }>('/health/alerts', params); } +export async function getAlert(id: string) { + return api.get(`/health/alerts/${id}`); +} + export async function acknowledgeAlert(id: string, version: number) { return api.put(`/health/alerts/${id}/acknowledge`, { version }); } diff --git a/apps/miniprogram/src/services/doctor/dialysis.ts b/apps/miniprogram/src/services/doctor/dialysis.ts index 229dd2e..a73ff0e 100644 --- a/apps/miniprogram/src/services/doctor/dialysis.ts +++ b/apps/miniprogram/src/services/doctor/dialysis.ts @@ -68,7 +68,7 @@ export interface DialysisStatistics { export async function listDialysisRecords( patientId: string, - params?: { page?: number; page_size?: number }, + params?: { page?: number; page_size?: number; status?: string }, ) { return api.get<{ data: DialysisRecord[]; total: number }>( `/health/patients/${patientId}/dialysis-records`, diff --git a/apps/miniprogram/src/services/health.ts b/apps/miniprogram/src/services/health.ts index 4d18c93..a720df7 100644 --- a/apps/miniprogram/src/services/health.ts +++ b/apps/miniprogram/src/services/health.ts @@ -63,6 +63,7 @@ export async function inputVitalSign(patientId: string, data: VitalSignInput) { body.urine_output_ml = Math.round(data.value); break; default: + console.warn(`[inputVitalSign] 未知的 indicator_type: ${data.indicator_type}`); break; } diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index 8b7fcc1..6bd121c 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -1,14 +1,7 @@ import Taro from '@tarojs/taro'; import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage'; -const BASE_URL = (() => { - const url = process.env.TARO_APP_API_URL || ''; - if (!url) return 'http://localhost:3000/api/v1'; - if (process.env.NODE_ENV === 'production' && url.startsWith('http://')) { - return url.replace('http://', 'https://'); - } - return url; -})(); +const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1'; interface ApiResponse { success: boolean; @@ -59,14 +52,7 @@ async function getHeaders(): Promise> { if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) { refreshHeadersCache(); } - // Token 过期预检查,提前 60 秒主动刷新 - if (!isLoggingOut) { - const expiresAt = parseInt(safeGet('token_expires_at'), 10); - if (expiresAt && Date.now() > expiresAt - 60_000) { - await tryRefreshToken(); - refreshHeadersCache(); - } - } + // Token 刷新已移至 401 重试路径,避免并发请求全部阻塞在 await tryRefreshToken() const headers: Record = { 'Content-Type': 'application/json' }; if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`; if (cachedPatientId) headers['X-Patient-Id'] = cachedPatientId; diff --git a/apps/miniprogram/src/stores/health.ts b/apps/miniprogram/src/stores/health.ts index c8887ec..f0713ae 100644 --- a/apps/miniprogram/src/stores/health.ts +++ b/apps/miniprogram/src/stores/health.ts @@ -19,6 +19,7 @@ interface HealthState { const CACHE_TTL = 5 * 60 * 1000; const TODAY_SUMMARY_TTL = 60_000; +const MAX_TREND_KEYS = 20; export const useHealthStore = create((set, get) => ({ todaySummary: null, @@ -51,7 +52,16 @@ export const useHealthStore = create((set, get) => ({ try { const resp = await healthApi.getTrend(indicator, range); const points = resp.data_points || []; - set((s) => ({ trendData: { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } } })); + set((s) => { + const updated = { ...s.trendData, [cacheKey]: { data: points, cachedAt: Date.now() } }; + // 超过上限时淘汰最早的缓存 + const keys = Object.keys(updated); + if (keys.length > MAX_TREND_KEYS) { + const oldest = keys.reduce((a, b) => updated[a].cachedAt < updated[b].cachedAt ? a : b); + delete updated[oldest]; + } + return { trendData: updated }; + }); return points; } catch { return []; diff --git a/apps/miniprogram/src/stores/ui.ts b/apps/miniprogram/src/stores/ui.ts index 5c15f21..78a4651 100644 --- a/apps/miniprogram/src/stores/ui.ts +++ b/apps/miniprogram/src/stores/ui.ts @@ -30,7 +30,7 @@ export const useUIStore = create((set, get) => ({ try { const saved = Taro.getStorageSync(STORAGE_KEY); if (saved === 'elder' || saved === 'normal') { - set({ mode: saved }); + if (get().mode !== saved) set({ mode: saved }); } } catch { /* storage 不可用时保持默认 */ } }, diff --git a/apps/miniprogram/src/styles/variables.scss b/apps/miniprogram/src/styles/variables.scss index 827e6e5..0afc52e 100644 --- a/apps/miniprogram/src/styles/variables.scss +++ b/apps/miniprogram/src/styles/variables.scss @@ -8,6 +8,7 @@ $pri-d: #8B3E1F; // 赤土深 $pri-surface: #F5F0EB; // 温润米底 $acc: #5B7A5E; // 鼠尾草绿 (success) $acc-l: #E8F0E8; // 成功浅 +$acc-d: #3D5A40; // 成功深(渐变中间色) $bg: #F5F0EB; // 主背景 (warm cream) $card: #FFFFFF; // 卡片白 $white: #FFFFFF; // 纯白(文字/图标在彩色底上) @@ -21,6 +22,7 @@ $dan: #B54A4A; // 危险 (muted red) $dan-l: #FDEAEA; // 危险浅 $wrn: #C4873A; // 警告 (warm amber) $wrn-l: #FFF3E0; // 警告浅 +$wrn-d: #8B6F4E; // 警告深(渐变中间色) // ─── 圆角 ─── $r: 16px; diff --git a/apps/miniprogram/src/utils/navigate.ts b/apps/miniprogram/src/utils/navigate.ts index 41c8419..e87b452 100644 --- a/apps/miniprogram/src/utils/navigate.ts +++ b/apps/miniprogram/src/utils/navigate.ts @@ -3,10 +3,5 @@ import Taro from '@tarojs/taro'; const LOGIN_PAGE = '/pages/login/index'; export function navigateToLogin() { - Taro.navigateTo({ - url: LOGIN_PAGE, - fail: () => { - Taro.reLaunch({ url: LOGIN_PAGE }); - }, - }); + Taro.reLaunch({ url: LOGIN_PAGE }); } diff --git a/apps/miniprogram/src/utils/secure-storage.ts b/apps/miniprogram/src/utils/secure-storage.ts index b1f7fc6..3c20bca 100644 --- a/apps/miniprogram/src/utils/secure-storage.ts +++ b/apps/miniprogram/src/utils/secure-storage.ts @@ -1,50 +1,24 @@ import Taro from '@tarojs/taro'; -import AES from 'crypto-js/aes'; -import Utf8 from 'crypto-js/enc-utf8'; -const ENCRYPTION_KEY = process.env.TARO_APP_ENCRYPTION_KEY || ''; - -if (!ENCRYPTION_KEY && process.env.NODE_ENV !== 'production') { - console.warn('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,敏感数据将以明文存储'); -} - -function encrypt(plaintext: string): string { - if (!ENCRYPTION_KEY) { - if (process.env.NODE_ENV === 'production') { - throw new Error('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,生产环境禁止明文存储'); - } - return plaintext; - } - return AES.encrypt(plaintext, ENCRYPTION_KEY).toString(); -} - -function decrypt(ciphertext: string): string | null { - if (!ENCRYPTION_KEY) { - if (process.env.NODE_ENV === 'production') { - throw new Error('[secure-storage] TARO_APP_ENCRYPTION_KEY 未设置,生产环境禁止明文读取'); - } - return ciphertext; - } - try { - const bytes = AES.decrypt(ciphertext, ENCRYPTION_KEY); - const result = bytes.toString(Utf8); - if (!result) return null; - return result; - } catch { - return null; - } -} +/** + * 持久化存储工具 — 小程序版本 + * + * 注意:此模块不执行客户端加密。 + * crypto-js 在微信开发者工具(Node.js 环境)中会触发 fd 错误导致卡死, + * 因此敏感数据依赖 HTTPS 传输 + 后端 AES-256-GCM 加密保护。 + * + * 导出函数名保留 secure* 前缀以保持调用点兼容,但实际为明文存储。 + * 如需启用客户端加密,请使用微信小程序原生 crypto API 或通过后端加解密。 + */ export function secureSet(key: string, value: string): void { - const encrypted = encrypt(value); - Taro.setStorageSync(key, encrypted); + Taro.setStorageSync(key, value); } export function secureGet(key: string): string { const raw = Taro.getStorageSync(key); if (!raw || typeof raw !== 'string') return ''; - const result = decrypt(raw); - return result ?? ''; + return raw; } export function secureRemove(key: string): void { diff --git a/apps/miniprogram/src/utils/statusTag.ts b/apps/miniprogram/src/utils/statusTag.ts index 709a55b..8463422 100644 --- a/apps/miniprogram/src/utils/statusTag.ts +++ b/apps/miniprogram/src/utils/statusTag.ts @@ -76,16 +76,10 @@ export function getStatusStyle(status: string): StatusStyle { return STATUS_COLORS[status] || DEFAULT_STYLE; } -/** 获取带透明度的状态背景(用于行内 style) */ -export function getStatusInlineStyle(status: string): { background: string; color: string; borderRadius: string; padding: string; fontSize: string } { +/** 获取状态行内样式(仅颜色),布局通过 .status-tag CSS 类控制 */ +export function getStatusInlineStyle(status: string): { background: string; color: string } { const s = getStatusStyle(status); - return { - background: s.background, - color: s.color, - borderRadius: '6px', - padding: '2px 8px', - fontSize: '24px', // 小程序最小字号 - }; + return { background: s.background, color: s.color }; } // 统一状态标签文案 diff --git a/apps/miniprogram/src/utils/validate.ts b/apps/miniprogram/src/utils/validate.ts index a9434cb..a94210f 100644 --- a/apps/miniprogram/src/utils/validate.ts +++ b/apps/miniprogram/src/utils/validate.ts @@ -16,7 +16,7 @@ export function num(rule: NumRule) { return { safeParse(value: number | undefined): ValidateResult { if (value === undefined || value === null) { - return rule.optional ? { ok: true, message: '' } : { ok: false, message: posMsg || '请输入有效数值' }; + return rule.optional ? { ok: true, message: '' } : { ok: false, message: rule.posMsg || '请输入有效数值' }; } if (isNaN(value)) return { ok: false, message: '请输入有效数值' }; if (rule.min !== undefined && value < rule.min) return { ok: false, message: rule.minMsg || `数值不能低于${rule.min}` }; diff --git a/crates/erp-auth/src/middleware/jwt_auth.rs b/crates/erp-auth/src/middleware/jwt_auth.rs index 47225af..9c3f3a4 100644 --- a/crates/erp-auth/src/middleware/jwt_auth.rs +++ b/crates/erp-auth/src/middleware/jwt_auth.rs @@ -9,6 +9,17 @@ use erp_core::types::{DataScope, TenantContext}; use crate::service::token_service::TokenService; +type DeptIds = Vec; +type DataScopes = std::collections::HashMap; +type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant); +type ScopeCacheMap = std::collections::HashMap; + +/// 用户权限数据缓存(user_id -> (department_ids, data_scopes, cached_at)) +static USER_SCOPE_CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new())); + +const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); + /// JWT authentication middleware function. /// /// Extracts the `Bearer` token from the `Authorization` header, validates it @@ -64,16 +75,20 @@ pub async fn jwt_auth_middleware_fn( return Err(AppError::Unauthorized); } - // 查询用户所属部门 ID 列表 - let department_ids = match &db { - Some(conn) => fetch_user_department_ids(claims.sub, claims.tid, conn).await, - None => vec![], + // 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存) + let cached = { + let cache = USER_SCOPE_CACHE.read().unwrap(); + cache.get(&claims.sub).and_then(|(depts, scopes, at)| { + if at.elapsed() < SCOPE_CACHE_TTL { + Some((depts.clone(), scopes.clone())) + } else { + None + } + }) }; - - // 查询每个权限的数据范围 - let permission_data_scopes = match &db { - Some(conn) => fetch_permission_data_scopes(claims.sub, claims.tid, conn).await, - None => std::collections::HashMap::new(), + let (department_ids, permission_data_scopes) = match cached { + Some(hit) => hit, + None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await, }; // 提取请求来源信息(IP + User-Agent),用于审计日志 @@ -174,3 +189,33 @@ async fn fetch_permission_data_scopes( } } } + +/// 从 DB 查询部门 + 权限范围,并写入缓存 +async fn fetch_and_cache_scopes( + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, + db: &Option, +) -> ( + Vec, + std::collections::HashMap, +) { + let depts = match db { + Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await, + None => vec![], + }; + let scopes = match db { + Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await, + None => std::collections::HashMap::new(), + }; + let mut cache = USER_SCOPE_CACHE.write().unwrap(); + cache.insert( + user_id, + (depts.clone(), scopes.clone(), std::time::Instant::now()), + ); + // 惰性淘汰过期条目,防止 HashMap 无限增长 + if cache.len() > 500 { + let now = std::time::Instant::now(); + cache.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL); + } + (depts, scopes) +} diff --git a/crates/erp-server/src/handlers/analytics.rs b/crates/erp-server/src/handlers/analytics.rs index 3c9fa64..3e49141 100644 --- a/crates/erp-server/src/handlers/analytics.rs +++ b/crates/erp-server/src/handlers/analytics.rs @@ -5,12 +5,29 @@ use tracing; use erp_core::types::ApiResponse; #[derive(Debug, Deserialize)] +#[allow(dead_code)] // 客户端上报结构体,字段后续接入分析表时使用 pub struct AnalyticsEvent { pub event: String, pub properties: Option, - #[allow(dead_code)] // 客户端上报字段,后续接入分析表时会使用 + #[serde(deserialize_with = "deserialize_flexible_timestamp")] pub timestamp: Option, pub page: Option, + pub user_id: Option, + pub patient_id: Option, +} + +fn deserialize_flexible_timestamp<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + let val = Option::::deserialize(de)?; + match val { + None => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s)), + Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())), + _ => Err(de::Error::custom("timestamp must be string or number")), + } } #[derive(Debug, Deserialize)] diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs index e9ed74f..178d0f2 100644 --- a/crates/erp-server/src/middleware/rate_limit.rs +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -5,9 +5,30 @@ use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use redis::AsyncCommands; use serde::Serialize; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::state::AppState; +/// Redis 连接失败时间戳缓存(毫秒),5 秒内复用失败状态避免重复连接尝试 +static REDIS_LAST_FAIL_MS: AtomicU64 = AtomicU64::new(0); +const REDIS_FAIL_CACHE_SECS: u64 = 5; + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn is_redis_cached_failed() -> bool { + let last = REDIS_LAST_FAIL_MS.load(Ordering::Relaxed); + last > 0 && now_ms().saturating_sub(last) < REDIS_FAIL_CACHE_SECS * 1000 +} + +fn mark_redis_failed() { + REDIS_LAST_FAIL_MS.store(now_ms(), Ordering::Relaxed); +} + /// 限流错误响应。 #[derive(Serialize)] struct RateLimitResponse { @@ -100,12 +121,21 @@ async fn apply_rate_limit( req: Request, next: Next, ) -> Response { + // 快速路径:Redis 在缓存期内已知不可用,跳过连接尝试 + if is_redis_cached_failed() { + if params.fail_close { + return service_unavailable(params.prefix); + } + return next.run(req).await; + } + let key = format!("rate_limit:{}:{}", params.prefix, identifier); let mut conn = match params.redis_client.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { - tracing::error!(error = %e, "Redis 连接失败 [{}]", params.prefix); + mark_redis_failed(); + tracing::warn!(error = %e, "Redis 连接失败 [{}]({}秒内不再重试)", params.prefix, REDIS_FAIL_CACHE_SECS); if params.fail_close { return service_unavailable(params.prefix); } @@ -116,7 +146,8 @@ async fn apply_rate_limit( let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await { Ok(n) => n, Err(e) => { - tracing::error!(error = %e, "Redis INCR 失败 [{}]", params.prefix); + mark_redis_failed(); + tracing::warn!(error = %e, "Redis INCR 失败 [{}]", params.prefix); if params.fail_close { return service_unavailable(params.prefix); } @@ -158,7 +189,8 @@ pub async fn account_lockout_middleware( let mut conn = match state.redis.get_multiplexed_async_connection().await { Ok(c) => c, Err(e) => { - tracing::error!(error = %e, "Redis 连接失败"); + mark_redis_failed(); + tracing::warn!(error = %e, "Redis 连接失败 [login_lockout]"); if fail_close { return service_unavailable("login_lockout"); } diff --git a/docs/discussions/2026-05-14-devtools-freeze-root-cause-fix.md b/docs/discussions/2026-05-14-devtools-freeze-root-cause-fix.md new file mode 100644 index 0000000..17aa8bc --- /dev/null +++ b/docs/discussions/2026-05-14-devtools-freeze-root-cause-fix.md @@ -0,0 +1,94 @@ +# DevTools 卡死根因分析与修复 + +> 日期: 2026-05-14 | 参与者: AI 辅助分析 + +## 背景 + +微信小程序在 DevTools 中频繁卡死,表现为: +- 所有 API 请求失败(`ERR_SSL_PROTOCOL_ERROR`) +- Tab 切换后页面长时间无响应(30s+) +- 登录流程点击无反应 +- DevTools 完全失去响应需强制关闭 + +## 根因分析 + +### 根因 1(CRITICAL):http→https 自动转换 + +**文件**: `apps/miniprogram/src/services/request.ts` + +```typescript +// 旧代码:production 模式自动将 http 替换为 https +if (process.env.NODE_ENV === 'production' && url.startsWith('http://')) { + return url.replace('http://', 'https://'); +} +``` + +**问题**: `taro build --type weapp` 默认是 production 构建。所有 API 请求从 `http://localhost:3000` 被强制改为 `https://localhost:3000`,而后端只支持 HTTP。每个请求都失败 → 错误处理循环 → React 重渲染 → 内存增长 → DevTools 卡死。 + +**修复**: 移除自动协议升级,让 `.env` 文件作为 URL 唯一来源。 + +### 根因 2(CRITICAL):getHeaders 中的同步 Token 刷新 + +**文件**: `apps/miniprogram/src/services/request.ts` + +```typescript +// 旧代码:每个请求前 await tryRefreshToken() +async function getHeaders() { + if (expiresAt && Date.now() > expiresAt - 60_000) { + await tryRefreshToken(); // 15 秒超时 + refreshHeadersCache(); + } +} +``` + +**问题**: 健康页 `didShow` 触发 4 个并发 API 请求,每个都先 `await getHeaders()`。当 Token 接近过期时,所有请求同时卡在 `tryRefreshToken()` 上。如果 refresh 超时(15 秒),加上 401 重试再超时(15 秒),**总计 30 秒无响应**。 + +**修复**: 移除 `getHeaders()` 中的 Token 刷新预检查,仅依赖已有的 401 重试逻辑(`request` 函数中 status === 401 时触发 `tryRefreshToken()`)。 + +### 根因 3(HIGH):DevTools 中 getPhoneNumber 不工作 + +**文件**: `apps/miniprogram/src/pages/login/index.tsx` + +**问题**: 微信登录流程需要两步: +1. `Taro.login()` → 获取 code → 后端返回 `bound: false`(mock openid 不在 DB) +2. `