Add endoscopy result viewer screen
New screen for viewing endoscopy results: 2×2 snapshot grid with simulated endoscopic view (radial gradient + specular highlight), diagnosis chips, conclusion, numbered recommendations, PDF/share actions, and full-screen image viewer with pagination. Wired via nav route `result:<id>` from ResultsScreen and exposed in Tweaks screen selector. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,7 @@ const SCREEN_OPTIONS = [
|
|||||||
{ id: 'appt:a1', lb: 'Детали приёма' },
|
{ id: 'appt:a1', lb: 'Детали приёма' },
|
||||||
{ id: 'results', lb: 'Результаты' },
|
{ id: 'results', lb: 'Результаты' },
|
||||||
{ id: 'result-audio', lb: 'Аудиограмма' },
|
{ id: 'result-audio', lb: 'Аудиограмма' },
|
||||||
|
{ id: 'result:r2', lb: 'Эндоскопия носоглотки' },
|
||||||
{ id: 'recovery', lb: 'Восстановление' },
|
{ id: 'recovery', lb: 'Восстановление' },
|
||||||
{ id: 'audiotest', lb: 'Тест слуха' },
|
{ id: 'audiotest', lb: 'Тест слуха' },
|
||||||
{ id: 'chat', lb: 'Чат' },
|
{ id: 'chat', lb: 'Чат' },
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,7 @@ import {
|
|||||||
} from './screens/screens-booking.jsx';
|
} from './screens/screens-booking.jsx';
|
||||||
import {
|
import {
|
||||||
ApptsTabScreen, ApptDetailScreen,
|
ApptsTabScreen, ApptDetailScreen,
|
||||||
ResultsScreen, ResultAudioScreen,
|
ResultsScreen, ResultAudioScreen, ResultEndoscopyScreen,
|
||||||
RecoveryScreen, AudioTestScreen,
|
RecoveryScreen, AudioTestScreen,
|
||||||
ChatTabScreen, ProfileTabScreen, QRScreen,
|
ChatTabScreen, ProfileTabScreen, QRScreen,
|
||||||
TelemedScreen, MedcardScreen, NotificationsScreen,
|
TelemedScreen, MedcardScreen, NotificationsScreen,
|
||||||
@@ -31,6 +31,7 @@ function renderScreen(screenId, nav, ctx) {
|
|||||||
case 'appt': return <ApptDetailScreen nav={nav} apptId={parts[1]} />;
|
case 'appt': return <ApptDetailScreen nav={nav} apptId={parts[1]} />;
|
||||||
case 'results': return <ResultsScreen nav={nav} />;
|
case 'results': return <ResultsScreen nav={nav} />;
|
||||||
case 'result-audio': return <ResultAudioScreen nav={nav} />;
|
case 'result-audio': return <ResultAudioScreen nav={nav} />;
|
||||||
|
case 'result': return <ResultEndoscopyScreen nav={nav} resultId={parts[1]} />;
|
||||||
case 'recovery': return <RecoveryScreen nav={nav} />;
|
case 'recovery': return <RecoveryScreen nav={nav} />;
|
||||||
case 'audiotest': return <AudioTestScreen nav={nav} />;
|
case 'audiotest': return <AudioTestScreen nav={nav} />;
|
||||||
case 'chat': return <ChatTabScreen nav={nav} />;
|
case 'chat': return <ChatTabScreen nav={nav} />;
|
||||||
|
|||||||
@@ -115,6 +115,182 @@ export function ApptDetailScreen({ nav, apptId }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EndoscopyTile({ label, time, seed = 0, big = false }) {
|
||||||
|
const variants = [
|
||||||
|
'radial-gradient(circle at 45% 40%, #FCE1DD 0%, #E9A29B 35%, #B36962 65%, #5A2624 100%)',
|
||||||
|
'radial-gradient(circle at 55% 50%, #FBD5D0 0%, #DC8981 40%, #9B4640 75%, #3F1B19 100%)',
|
||||||
|
'radial-gradient(circle at 40% 35%, #FFE4DE 0%, #E7988F 40%, #A55651 70%, #4A1E1B 100%)',
|
||||||
|
'radial-gradient(circle at 50% 55%, #FCDAD4 0%, #E5968C 35%, #A8544E 75%, #442220 100%)',
|
||||||
|
];
|
||||||
|
const insetPad = big ? 14 : 8;
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', background: '#0B0606', borderRadius: big ? 22 : 14, overflow: 'hidden', aspectRatio: '1', width: '100%' }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: insetPad, borderRadius: '50%',
|
||||||
|
background: variants[seed % 4],
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: insetPad, borderRadius: '50%',
|
||||||
|
boxShadow: 'inset 0 0 30px rgba(0,0,0,0.65), inset 0 0 6px rgba(255,255,255,0.15)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
{/* subtle highlight spot (specular) */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: `${22 + (seed % 3) * 6}%`, left: `${30 + (seed % 4) * 5}%`,
|
||||||
|
width: big ? 26 : 14, height: big ? 26 : 14, borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.55) 0%, transparent 70%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', left: insetPad + 4, right: insetPad + 4, bottom: insetPad + 4,
|
||||||
|
padding: big ? '8px 12px' : '5px 8px',
|
||||||
|
background: 'rgba(0,0,0,0.55)', borderRadius: 8,
|
||||||
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
color: '#fff', fontSize: big ? 13 : 11,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 700 }}>{label}</span>
|
||||||
|
<span style={{ opacity: .7, fontFamily: 'var(--font-narrow)' }}>{time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultEndoscopyScreen({ nav, resultId = 'r2' }) {
|
||||||
|
const r = CLINIC_DATA.results.find(x => x.id === resultId) || CLINIC_DATA.results.find(x => x.kind === 'image');
|
||||||
|
const doctor = CLINIC_DATA.doctors.find(d => d.id === r.doctor);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ label: 'Носоглотка', time: '10:32' },
|
||||||
|
{ label: 'Аденоиды', time: '10:33' },
|
||||||
|
{ label: 'Хоана лев.', time: '10:34' },
|
||||||
|
{ label: 'Хоана прав.', time: '10:35' },
|
||||||
|
];
|
||||||
|
const diagnoses = ['Гипертрофия аденоидов III ст.'];
|
||||||
|
const conclusion = 'Слизистая носоглотки умеренно гиперемирована, отёчна. Глоточная миндалина (аденоиды) III степени, заполняет просвет хоан на 2/3 с обеих сторон. Отделяемое слизистое. Устья слуховых труб обозримы, без особенностей.';
|
||||||
|
const recommendations = [
|
||||||
|
'Консультация ЛОР-хирурга в течение 2 недель',
|
||||||
|
'Курс промываний «Кукушка» (5 процедур)',
|
||||||
|
'Контрольная эндоскопия через 1 месяц',
|
||||||
|
'Обсудить показания к аденотомии',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 40 }}>
|
||||||
|
<ScreenHeader title={r.name} subtitle={`${r.date} · ${images.length} снимков`} onBack={() => nav.pop()} rightIcon={I.doc} />
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px' }}>
|
||||||
|
<div className="card" style={{ padding: 14, display: 'flex', gap: 12, alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
<Avatar init={doctor.init} size={44} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="sub" style={{ fontSize: 11, marginBottom: 2 }}>Исследование провёл</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700 }}>{doctor.name.split(' ').slice(0,2).join(' ')}</div>
|
||||||
|
</div>
|
||||||
|
<span className="chip chip-success">Готово</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<button key={i} onClick={() => setSelected(i)} className="press" style={{ padding: 0, background: 'transparent', borderRadius: 14 }}>
|
||||||
|
<EndoscopyTile label={img.label} time={img.time} seed={i} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="h-row" style={{ marginBottom: 10 }}>Диагноз</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{diagnoses.map(d => <span key={d} className="chip chip-warm" style={{ fontSize: 13, padding: '7px 12px' }}>{d}</span>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="h-row" style={{ marginBottom: 8 }}>Заключение</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.55 }}>{conclusion}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="h-row" style={{ marginBottom: 6 }}>Рекомендации</div>
|
||||||
|
{recommendations.map((rec, i, a) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, padding: '10px 0', alignItems: 'flex-start' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: 999, background: 'var(--c-primary-100)',
|
||||||
|
color: 'var(--c-primary-darker)', fontSize: 12, fontWeight: 700,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1,
|
||||||
|
}}>{i + 1}</div>
|
||||||
|
<div style={{ fontSize: 14, color: 'var(--c-fg-2)', lineHeight: 1.5 }}>{rec}</div>
|
||||||
|
</div>
|
||||||
|
{i < a.length - 1 && <div className="divider" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<button className="btn-p block">
|
||||||
|
<I.doc size={18} /> Скачать PDF
|
||||||
|
</button>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={() => nav.push('chat')} className="btn-g" style={{ flex: 1, padding: 14 }}>
|
||||||
|
<I.chat size={16} /> Обсудить
|
||||||
|
</button>
|
||||||
|
<button className="btn-g" style={{ flex: 1, padding: 14 }}>
|
||||||
|
<I.arrow size={16} /> Поделиться
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected !== null && (
|
||||||
|
<div
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.95)', zIndex: 200,
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '50px 20px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: '#fff' }}>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setSelected(null); }} className="press" style={{
|
||||||
|
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<I.close size={18} style={{ color: '#fff' }} />
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700 }}>{images[selected].label}</div>
|
||||||
|
<button onClick={(e) => e.stopPropagation()} className="press" style={{
|
||||||
|
width: 38, height: 38, borderRadius: 999, background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<I.doc size={18} style={{ color: '#fff' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0 20px' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 340 }}>
|
||||||
|
<EndoscopyTile label={images[selected].label} time={images[selected].time} seed={selected} big />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px 20px 36px', display: 'flex', justifyContent: 'center', gap: 8 }}>
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setSelected(i); }}
|
||||||
|
style={{
|
||||||
|
width: i === selected ? 24 : 8, height: 8, borderRadius: 999,
|
||||||
|
background: i === selected ? '#fff' : 'rgba(255,255,255,0.35)',
|
||||||
|
border: 0, padding: 0, cursor: 'pointer', transition: 'width .2s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ResultsScreen({ nav }) {
|
export function ResultsScreen({ nav }) {
|
||||||
const { results, doctors } = CLINIC_DATA;
|
const { results, doctors } = CLINIC_DATA;
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user