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 { function markdownToHtml(md: string): string {
const escaped = sanitizeHtml(md); 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(/^(#{1,3}) (.+)$/gm, (_, h: string, t: string) => `<h${h.length}>${t}</h${h.length}>`)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>') .replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li>$1</li>') .replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>')
.replace(/\n\n/g, '<br/><br/>') .replace(/\n\n/g, '<br/><br/>')
.replace(/\n/g, '<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() { export default function AiReportDetail() {

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ const LOGGED_IN_GROUPS: MenuGroup[] = [
{ {
title: '生活服务', title: '生活服务',
items: [ 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' }, { 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, retryCount: 3,
}; };
const MAX_LIVE_READINGS = 200;
export class BLEManager { export class BLEManager {
private adapters: DeviceAdapter[] = []; private adapters: DeviceAdapter[] = [];
private connection: BLEConnection | null = null; private connection: BLEConnection | null = null;
@@ -193,7 +195,10 @@ export class BLEManager {
res.value, res.value,
); );
if (newReadings.length > 0) { 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.dataBuffer.push(newReadings);
this.onReadings?.(newReadings); this.onReadings?.(newReadings);
} }

View File

@@ -132,6 +132,13 @@ export class DataBuffer {
const excess = total - this.config.maxTotal; const excess = total - this.config.maxTotal;
this.buckets[0] = this.buckets[0].slice(excess); 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 { private persistCurrentBucket(): void {

View File

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

View File

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