fix(mp): T40 UI 审计修复 — 28 项设计系统合规 + 安全加固 + 讨论记录
T40 UI 审计修复(60 页面全覆盖): - 新增 $acc-d/$wrn-d 渐变中间色变量,修复首页轮播渐变硬编码 - 替换 8 处裸 white 为 $white 设计变量(5 个 SCSS 文件) - 修复 7 处触摸目标 40/44px → 48px(健康/消息/咨询/预约/首页) - 3 页面新增 Loading 状态(体征录入/个人中心/就诊人添加) - statusTag 移除硬编码布局值,改用 SCSS mixin 控制 - 医生端 14 页面架构 Hook 层补充(useThrottledDidShow 替换 useEffect) - 移除 action-inbox 未使用 import 安全 P0 修复: - JWT 中间件加固:token 类型校验 + 过期预检 + 类型别名简化 - 速率限制增强:滑动窗口 + 暴力破解防护 - analytics handler 错误处理完善 文档: - T40 审计报告(24 PASS / 36 PASS_WITH_ISSUES / 0 NEEDS_WORK) - 5 份 DevTools/性能审计讨论记录 - wiki 症状导航 + 小程序章节更新
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<ConsultationMessage[]>([]);
|
||||
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 <Loading />;
|
||||
|
||||
const isOpen = session?.status !== 'closed';
|
||||
@@ -160,9 +203,14 @@ export default function ConsultationDetail() {
|
||||
scrollIntoView={scrollViewRef.current}
|
||||
scrollWithAnimation
|
||||
>
|
||||
{messages.map((msg, idx) => {
|
||||
{hiddenCount > 0 && (
|
||||
<View className='msg-truncated-hint'>
|
||||
<Text className='msg-truncated-hint__text'>已隐藏较早的 {hiddenCount} 条消息</Text>
|
||||
</View>
|
||||
)}
|
||||
{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 (
|
||||
<View key={msg.id}>
|
||||
{showDateDivider && (
|
||||
@@ -170,7 +218,7 @@ export default function ConsultationDetail() {
|
||||
<Text className='msg-date-divider__text'>{getDateLabel(msg.created_at)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View id={`msg-${idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
|
||||
<View id={`msg-${hiddenCount + idx + 1}`} className={`msg-row ${isSelf ? 'msg-row--self' : ''}`}>
|
||||
{!isSelf && (
|
||||
<View className='msg-avatar'>
|
||||
<Text className='msg-avatar-char'>{doctorInitial}</Text>
|
||||
@@ -193,7 +241,7 @@ export default function ConsultationDetail() {
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{messages.length === 0 && (
|
||||
{renderMessages.length === 0 && (
|
||||
<View className='chat-empty'>
|
||||
<Text className='chat-empty__text'>暂无消息,发送第一条消息开始对话</Text>
|
||||
</View>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user