fix(mp): 二轮审计修复 — ScrollView嵌套/InputField重建/markdown分组/BLE上限/缓存清理

CRITICAL: ai-report/list PageShell scroll=false 修复双重滚动冲突
HIGH: dialysis/create InputField 提取为独立组件避免 render 销毁重建
MEDIUM: markdownToHtml 连续<li>合并到单个<ul>
MEDIUM: 咨询详情页图片添加 lazyLoad
MEDIUM: BLEManager readings 添加 MAX_LIVE_READINGS=200 上限
MEDIUM: DataBuffer trimToMax 时重建 seenKeys 保持一致性
MEDIUM: auth.ts logout 清理模块级缓存变量
LOW: request.ts safeReLaunch 添加 console.warn + doRefresh 死锁警告注释
This commit is contained in:
iven
2026-05-17 18:54:27 +08:00
parent 66aef532fa
commit fcce2f5c51
9 changed files with 59 additions and 33 deletions

View File

@@ -19,14 +19,15 @@ const TYPE_LABELS: Record<string, string> = {
function markdownToHtml(md: string): string {
const escaped = sanitizeHtml(md);
return escaped
const html = escaped
.replace(/^(#{1,3}) (.+)$/gm, (_, h: string, t: string) => `<h${h.length}>${t}</h${h.length}>`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>')
.replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, '<br/>');
// 合并连续 <li> 到单个 <ul>
return html.replace(/(<li>[\s\S]*?<\/li>)(?:<br\/>?\s*(<li>[\s\S]*?<\/li>))*/g, (match) => `<ul>${match}</ul>`);
}
export default function AiReportDetail() {

View File

@@ -70,7 +70,7 @@ export default function AiReportList() {
}
return (
<PageShell className={modeClass}>
<PageShell scroll={false} className={modeClass}>
<View className='page-title'>AI </View>
<ScrollView scrollY className='report-scroll' onScrollToLower={loadMore}>
{list.map((item) => {

View File

@@ -190,6 +190,7 @@ export default function ConsultationDetail() {
className='chat-msg__image'
src={msg.content}
mode='widthFix'
lazyLoad
onClick={() => Taro.previewImage({ urls: [msg.content], current: msg.content })}
/>
) : (

View File

@@ -13,6 +13,23 @@ import './index.scss';
const DIALYSIS_TYPES = ['HD', 'HDF', 'HF'];
function InputField({ label, value, placeholder, type = 'digit', onChange }: {
label: string; value: string; placeholder: string; type?: string; onChange: (v: string) => void;
}) {
return (
<View className='form-row'>
<Text className='form-label'>{label}</Text>
<Input
className='form-input'
type={type as any}
placeholder={placeholder}
value={value}
onInput={(e) => onChange(e.detail.value)}
/>
</View>
);
}
interface FormState {
patient_id: string;
dialysis_date: string;
@@ -159,21 +176,6 @@ export default function DialysisCreate() {
if (loading) return <Loading />;
const InputField = ({ label, field, placeholder, type = 'digit' }: {
label: string; field: keyof FormState; placeholder: string; type?: string;
}) => (
<View className='form-row'>
<Text className='form-label'>{label}</Text>
<Input
className='form-input'
type={type as any}
placeholder={placeholder}
value={form[field]}
onInput={(e) => updateField(field, e.detail.value)}
/>
</View>
);
return (
<PageShell safeBottom={false} className={modeClass}>
<ContentCard className='section'>
@@ -208,30 +210,30 @@ export default function DialysisCreate() {
<Text className='form-value'>{form.dialysis_type}</Text>
</Picker>
</View>
<InputField label='透析时长' field='dialysis_duration' placeholder='分钟' type='number' />
<InputField label='血流速' field='blood_flow_rate' placeholder='ml/min' type='number' />
<InputField label='透析时长' value={form.dialysis_duration} placeholder='分钟' type='number' onChange={(v) => updateField('dialysis_duration', v)} />
<InputField label='血流速' value={form.blood_flow_rate} placeholder='ml/min' type='number' onChange={(v) => updateField('blood_flow_rate', v)} />
</ContentCard>
<ContentCard className='section'>
<Text className='section-title'></Text>
<InputField label='干体重' field='dry_weight' placeholder='kg' />
<InputField label='透前体重' field='pre_weight' placeholder='kg' />
<InputField label='透后体重' field='post_weight' placeholder='kg' />
<InputField label='干体重' value={form.dry_weight} placeholder='kg' onChange={(v) => updateField('dry_weight', v)} />
<InputField label='透前体重' value={form.pre_weight} placeholder='kg' onChange={(v) => updateField('pre_weight', v)} />
<InputField label='透后体重' value={form.post_weight} placeholder='kg' onChange={(v) => updateField('post_weight', v)} />
</ContentCard>
<ContentCard className='section'>
<Text className='section-title'></Text>
<InputField label='透前收缩压' field='pre_bp_systolic' placeholder='mmHg' type='number' />
<InputField label='透前舒张压' field='pre_bp_diastolic' placeholder='mmHg' type='number' />
<InputField label='透后收缩压' field='post_bp_systolic' placeholder='mmHg' type='number' />
<InputField label='透后舒张压' field='post_bp_diastolic' placeholder='mmHg' type='number' />
<InputField label='透前心率' field='pre_heart_rate' placeholder='bpm' type='number' />
<InputField label='透后心率' field='post_heart_rate' placeholder='bpm' type='number' />
<InputField label='透前收缩压' value={form.pre_bp_systolic} placeholder='mmHg' type='number' onChange={(v) => updateField('pre_bp_systolic', v)} />
<InputField label='透前舒张压' value={form.pre_bp_diastolic} placeholder='mmHg' type='number' onChange={(v) => updateField('pre_bp_diastolic', v)} />
<InputField label='透后收缩压' value={form.post_bp_systolic} placeholder='mmHg' type='number' onChange={(v) => updateField('post_bp_systolic', v)} />
<InputField label='透后舒张压' value={form.post_bp_diastolic} placeholder='mmHg' type='number' onChange={(v) => updateField('post_bp_diastolic', v)} />
<InputField label='透前心率' value={form.pre_heart_rate} placeholder='bpm' type='number' onChange={(v) => updateField('pre_heart_rate', v)} />
<InputField label='透后心率' value={form.post_heart_rate} placeholder='bpm' type='number' onChange={(v) => updateField('post_heart_rate', v)} />
</ContentCard>
<ContentCard className='section'>
<Text className='section-title'></Text>
<InputField label='超滤量' field='ultrafiltration_volume' placeholder='ml' type='number' />
<InputField label='超滤量' value={form.ultrafiltration_volume} placeholder='ml' type='number' onChange={(v) => updateField('ultrafiltration_volume', v)} />
<View className='form-row form-row--textarea'>
<Text className='form-label'></Text>
<Textarea

View File

@@ -57,7 +57,7 @@ const LOGGED_IN_GROUPS: MenuGroup[] = [
{
title: '生活服务',
items: [
{ label: '积分商城', icon: '礼', bg: 'pri-l', color: 'pri', path: '/pages/mall/index', isSwitchTab: true },
{ label: '积分商城', icon: '礼', bg: 'pri-l', color: 'pri', path: '/pages/mall/index' },
{ label: '线下活动', icon: '活', bg: 'acc-l', color: 'acc', path: '/pages/pkg-profile/events/index' },
],
},

View File

@@ -16,6 +16,8 @@ const DEFAULT_CONFIG: BLEManagerConfig = {
retryCount: 3,
};
const MAX_LIVE_READINGS = 200;
export class BLEManager {
private adapters: DeviceAdapter[] = [];
private connection: BLEConnection | null = null;
@@ -193,7 +195,10 @@ export class BLEManager {
res.value,
);
if (newReadings.length > 0) {
this.readings = [...this.readings, ...newReadings];
const combined = [...this.readings, ...newReadings];
this.readings = combined.length > MAX_LIVE_READINGS
? combined.slice(-MAX_LIVE_READINGS)
: combined;
this.dataBuffer.push(newReadings);
this.onReadings?.(newReadings);
}

View File

@@ -132,6 +132,13 @@ export class DataBuffer {
const excess = total - this.config.maxTotal;
this.buckets[0] = this.buckets[0].slice(excess);
}
// 重建 seenKeys 与实际数据一致
this.seenKeys.clear();
for (const bucket of this.buckets) {
for (const r of bucket) {
this.seenKeys.add(this.dedupeKey(r));
}
}
}
private persistCurrentBucket(): void {

View File

@@ -185,6 +185,7 @@ async function tryRefreshToken(): Promise<boolean> {
return refreshPromise;
}
// 直接调用 Taro.request() 而非 request(),避免 ConcurrencyLimiter 死锁
async function doRefresh(): Promise<boolean> {
const refreshToken = secureGet('refresh_token');
if (!refreshToken) return false;
@@ -227,7 +228,9 @@ let reLaunchPromise: Promise<void> | null = null;
function safeReLaunch(url: string): void {
if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, () => {}).then(() => {
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => {
console.warn('[request] reLaunch failed:', err);
}).then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000);
});
}

View File

@@ -241,6 +241,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
markLoggingOut();
clearRequestCache();
setCachedPatientId('');
// 清理模块级缓存
cachedUserJson = '';
cachedUserObj = null;
cachedRolesJson = '';
cachedRolesObj = [];
cachedPatientJson = '';
cachedPatientObj = null;
secureRemove('access_token');
secureRemove('refresh_token');
secureRemove('token_expires_at');