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:
iven
2026-05-14 23:12:54 +08:00
parent 447126b6c5
commit 8f353946e1
90 changed files with 2089 additions and 830 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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(() => {