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:
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user