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