// Room detail screen — gallery + details + contact CTA const { useState: useStateRD, useMemo: useMemoRD, useEffect: useEffectRD } = React; const CONTACT_QR_URL = './assets/wechat-qr.jpg'; const CONTACT_WECHAT_ID = 'urrent'; function pickRoom(propertyDetail, selectedRoom) { if (!propertyDetail?.rooms?.length) return selectedRoom; const targetKey = selectedRoom.roomKey || selectedRoom.id; const targetCanonicalId = selectedRoom.canonicalRoomId; const targetName = selectedRoom.name; const matched = propertyDetail.rooms.find(item => (targetKey && (item.roomKey === targetKey || item.id === targetKey)) || (targetCanonicalId && item.canonicalRoomId === targetCanonicalId) || (targetName && item.name === targetName) ); return matched ? { ...selectedRoom, ...matched } : selectedRoom; } function normalizeGalleryLabel(item, index) { const raw = item?.label || item?.title || item?.name || item?.alt || item?.comment || ''; const text = String(raw).trim(); if (!text) return index === 0 ? '物件外观' : `物件图片 ${index + 1}`; if (text.includes('外観')) return '物件外观'; if (text.includes('周辺')) return '周边环境'; return text.length > 18 ? text.slice(0, 18) + '…' : text; } function RoomDetail({ property, room, onBack }) { const [detailProperty, setDetailProperty] = useStateRD(null); const [detailLoading, setDetailLoading] = useStateRD(false); const gallery = useMemoRD(() => { const currentProperty = detailProperty ? { ...property, ...detailProperty } : property; const currentRoom = pickRoom(detailProperty, room); const imgs = []; const seen = new Set(); const pushImage = (url, label, contain = false) => { const resolved = resolveImg(url); if (!resolved || seen.has(resolved)) return; seen.add(resolved); imgs.push({ url: resolved, label, contain }); }; const fp = currentRoom.floorplanImageLocalUrl || currentRoom.layoutImage || currentRoom.floorplanImageRemoteUrl; pushImage(fp, '户型图', true); (currentProperty.galleryImages || []).forEach((item, index) => { pushImage( item?.localUrl || item?.url || item?.remoteUrl || item?.imageUrl, normalizeGalleryLabel(item, index), false ); }); pushImage( currentProperty.propertyImageLocalUrl || currentProperty.propertyImageRemoteUrl || currentProperty.image, '物件外观', false ); return imgs; }, [detailProperty, property, room]); const [idx, setIdx] = useStateRD(0); const [imgErr, setImgErr] = useStateRD({}); const [contactOpen, setContactOpen] = useStateRD(false); const [saveLabel, setSaveLabel] = useStateRD('保存二维码'); const currentProperty = detailProperty ? { ...property, ...detailProperty } : property; const currentRoom = pickRoom(detailProperty, room); const cur = gallery[idx]; useEffectRD(() => { let cancelled = false; setDetailProperty(null); setDetailLoading(true); setIdx(0); setImgErr({}); apiFetch('/api/v1/properties/' + encodeURIComponent(property.propertyKey)) .then((data) => { if (cancelled) return; setDetailProperty(data); }) .catch((err) => { if (!cancelled) console.warn('load property detail failed', err); }) .finally(() => { if (!cancelled) setDetailLoading(false); }); return () => { cancelled = true; }; }, [property.propertyKey]); useEffectRD(() => { setIdx(0); }, [currentRoom.roomKey, gallery.length]); const access = currentProperty.access && currentProperty.access.length > 0 ? currentProperty.access : (currentProperty.accessText || '').split(/\s*\|\s*/).filter(Boolean).length > 0 ? (currentProperty.accessText || '').split(/\s*\|\s*/).filter(Boolean) : currentProperty.detailTransportText ? [currentProperty.detailTransportText] : []; const highlightTags = Array.from(new Set([...(currentProperty.featureTags || []), ...(currentRoom.feature ? [currentRoom.feature] : [])])); const facilityTags = Array.from(new Set([...(currentProperty.facilityTags || []), ...(currentRoom.requirement ? [currentRoom.requirement] : [])])); const openContact = () => { setContactOpen(true); }; const saveQrCode = async () => { try { const res = await fetch(CONTACT_QR_URL); const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = objectUrl; a.download = 'urrent-wechat-qr.jpg'; document.body.appendChild(a); a.click(); a.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1500); setSaveLabel('已开始保存'); window.setTimeout(() => setSaveLabel('保存二维码'), 1800); } catch (err) { console.error('save qr failed', err); setSaveLabel('保存失败,请重试'); window.setTimeout(() => setSaveLabel('保存二维码'), 2200); } }; return (
{/* Top bar */}
房间详情
{currentRoom.name}
{/* Gallery */}
{cur && !imgErr[idx] ? ( {cur.label} setImgErr(e => ({ ...e, [idx]: true }))} /> ) : (
)} {cur?.label &&
{cur.label}
} {gallery.length > 0 && (
{idx + 1} / {gallery.length}
)}
{gallery.length > 1 && (
{gallery.map((g, i) => ( ))}
)}
{/* Property identity */}
{currentProperty.skcs || currentProperty.areaName} · {currentProperty.regionName}
{currentProperty.name}
{currentRoom.name}
{currentProperty.address && (
{currentProperty.address}
)} {detailLoading && (
正在补充物件详情…
)}
{/* Rent hero */}
月租金(含管理费)
{currentRoom.rent || currentProperty.rent || '—'}
{currentRoom.commonfee && (
管理费 {currentRoom.commonfee}
)} {currentRoom.shikikin && (
敷金 {currentRoom.shikikin}
)}
{/* Spec grid */}
{[ { label: '户型', value: currentRoom.type }, { label: '面积', value: currentRoom.floorspace }, { label: '楼层', value: currentRoom.floor }, { label: '押金', value: currentRoom.shikikin || '—' }, ].map(s => (
{s.label}
{s.value || '—'}
))}
{/* Access */} {access.length > 0 && (
交通
{access.map((a, i) => (
{a}
))}
)} {highlightTags.length > 0 && (
物件亮点
{highlightTags.map((tag, index) => ( {tag} ))}
)} {facilityTags.length > 0 && (
配套设备
{facilityTags.map((tag, index) => ( {tag} ))}
)} {/* Property meta */}
房源信息
{currentProperty.parkingInfoText && (
停车信息
{currentProperty.parkingInfoText}
)}
信息来自 UR都市機構 官方网站,仅供参考。申请以 UR 最终审核为准。
{/* Bottom CTA */}
{contactOpen && (
setContactOpen(false)}>
e.stopPropagation()}>
微信客服
请加微信客服
微信ID {CONTACT_WECHAT_ID}
微信客服二维码
长按或保存二维码后,使用微信扫码添加客服。
)}
); } function MetaRow({ label, value }) { return (
{label}
{value}
); } const rdStyles = { shell: { position: 'fixed', inset: 0, background: '#FAF8F5', display: 'flex', flexDirection: 'column', zIndex: 50, animation: 'slideInRight 240ms cubic-bezier(.2,.8,.2,1)', }, topBar: { padding: 'calc(12px + env(safe-area-inset-top, 0px)) 14px 10px', display: 'flex', alignItems: 'center', gap: 10, background: '#FAF8F5', borderBottom: '1px solid #EDE6D9', }, backBtn: { width: 36, height: 36, borderRadius: 18, border: '1px solid #EDE6D9', background: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, }, topTitle: { flex: 1, minWidth: 0 }, topTitleMain: { fontSize: 14, fontWeight: 600, color: '#1A1A1A', letterSpacing: '0.01em' }, topTitleSub: { fontSize: 11, color: '#8A7F72', marginTop: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, scroll: { flex: 1, overflowY: 'auto', overflowX: 'hidden', paddingBottom: 'calc(132px + env(safe-area-inset-bottom, 0px))' }, gallery: { background: '#FFFFFF', padding: '12px 14px 14px' }, galleryMain: { width: '100%', aspectRatio: '4 / 3', borderRadius: 14, overflow: 'hidden', background: '#F4EFE4', position: 'relative', }, galleryFallback: { width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', }, galleryBadge: { position: 'absolute', top: 10, left: 10, padding: '4px 10px', borderRadius: 999, background: 'rgba(26, 26, 26, 0.72)', color: '#FFFFFF', fontSize: 11, fontWeight: 500, backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)', }, galleryCount: { position: 'absolute', bottom: 10, right: 10, padding: '3px 9px', borderRadius: 999, background: 'rgba(26, 26, 26, 0.6)', color: '#FFFFFF', fontSize: 10, fontWeight: 500, fontFamily: '"Inter", sans-serif', }, thumbRow: { display: 'flex', gap: 8, marginTop: 10 }, thumb: { flex: '0 0 72px', height: 56, borderRadius: 8, border: '2px solid #E4DFD6', overflow: 'hidden', padding: 0, background: '#F4EFE4', cursor: 'pointer', position: 'relative', transition: 'border-color 150ms ease', }, thumbLabel: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: '2px 4px', fontSize: 9, color: '#fff', background: 'rgba(26, 26, 26, 0.55)', fontWeight: 500, textAlign: 'center', }, block: { padding: '16px 18px 14px', background: '#FFFFFF', borderTop: '1px solid #EDE6D9', }, ward: { display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#8A7F72', fontWeight: 500, letterSpacing: '0.02em', marginBottom: 6, }, propName: { fontSize: 18, fontWeight: 700, color: '#1A1A1A', lineHeight: 1.3, letterSpacing: '0.005em', }, roomTag: { display: 'inline-block', marginTop: 8, padding: '4px 10px', borderRadius: 999, background: '#F4EADB', color: '#8a7256', fontSize: 11, fontWeight: 600, letterSpacing: '0.03em', }, addressText: { marginTop: 10, fontSize: 12, color: '#6B6258', lineHeight: 1.6, }, detailLoading: { marginTop: 10, display: 'inline-flex', alignItems: 'center', padding: '4px 10px', borderRadius: 999, background: '#F7F0E5', color: '#8A7256', fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', }, rentHero: { margin: '10px 14px 0', padding: '16px 18px', background: 'linear-gradient(135deg, #FBEFE6 0%, #F4EADB 100%)', borderRadius: 14, border: '1px solid #F2E3CF', }, rentHeroLabel: { fontSize: 11, color: '#8a7256', fontWeight: 500, letterSpacing: '0.05em', }, rentHeroMain: { marginTop: 4 }, rentHeroNum: { fontSize: 28, fontWeight: 700, color: '#C6572B', fontFamily: '"Inter", "Noto Sans JP", sans-serif', letterSpacing: '-0.02em', }, rentHeroSub: { fontSize: 11, color: '#8a7256', fontWeight: 500, marginTop: 2, }, specGrid: { margin: '12px 14px 0', background: '#FFFFFF', borderRadius: 14, border: '1px solid #EDE6D9', display: 'grid', gridTemplateColumns: '1fr 1fr', overflow: 'hidden', }, specCell: { padding: '14px 16px', borderRight: '1px solid #EDE6D9', borderBottom: '1px solid #EDE6D9', }, specLabel: { fontSize: 10, color: '#8A7F72', fontWeight: 500, letterSpacing: '0.05em' }, specValue: { fontSize: 15, fontWeight: 600, color: '#1A1A1A', marginTop: 4, fontFamily: '"Inter", "Noto Sans JP", sans-serif', }, section: { margin: '14px 14px 0', padding: '14px 18px', background: '#FFFFFF', borderRadius: 14, border: '1px solid #EDE6D9', }, sectionTitle: { fontSize: 12, color: '#8A7F72', fontWeight: 600, letterSpacing: '0.06em', marginBottom: 10, }, accessList: { display: 'flex', flexDirection: 'column', gap: 8 }, accessItem: { display: 'flex', alignItems: 'flex-start', gap: 10 }, accessIcon: { width: 24, height: 24, borderRadius: 6, background: '#F4EFE4', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, marginTop: 1, }, accessText: { fontSize: 13, color: '#1A1A1A', lineHeight: 1.5, }, tagWrap: { display: 'flex', flexWrap: 'wrap', gap: 8, }, featureTag: { display: 'inline-flex', alignItems: 'center', padding: '8px 12px', borderRadius: 999, background: '#FFF1E8', color: '#A85D37', fontSize: 11, fontWeight: 600, lineHeight: 1.2, }, facilityTag: { display: 'inline-flex', alignItems: 'center', padding: '8px 12px', borderRadius: 999, background: '#F4EFE4', color: '#6B6258', fontSize: 11, fontWeight: 600, lineHeight: 1.2, }, metaList: { display: 'flex', flexDirection: 'column' }, metaRow: { display: 'flex', gap: 12, padding: '10px 0', borderBottom: '1px dashed #F4EFE4', }, metaLabel: { fontSize: 12, color: '#8A7F72', fontWeight: 500, flex: '0 0 72px', }, metaValue: { fontSize: 12, color: '#1A1A1A', flex: 1, wordBreak: 'break-all', lineHeight: 1.5, }, bodyText: { fontSize: 12, color: '#1A1A1A', lineHeight: 1.7, whiteSpace: 'pre-wrap', }, disclaim: { margin: '16px 18px 8px', fontSize: 10, color: '#A89B82', lineHeight: 1.6, }, ctaBar: { position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 90, background: 'rgba(250, 248, 245, 0.95)', backdropFilter: 'blur(16px) saturate(180%)', WebkitBackdropFilter: 'blur(16px) saturate(180%)', borderTop: '1px solid #EDE6D9', padding: '12px 16px calc(16px + env(safe-area-inset-bottom, 0px))', }, ctaBtn: { width: '100%', padding: '14px 20px', borderRadius: 14, border: 'none', background: '#C6572B', color: '#FFFFFF', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, fontFamily: 'inherit', cursor: 'pointer', boxShadow: '0 4px 14px rgba(198, 87, 43, 0.28)', }, ctaMain: { fontSize: 15, fontWeight: 700, letterSpacing: '0.02em' }, ctaSub: { fontSize: 10, fontWeight: 500, opacity: 0.88, letterSpacing: '0.08em' }, modalBackdrop: { position: 'fixed', inset: 0, zIndex: 120, background: 'rgba(20,18,14,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 16px 40px', animation: 'fadeIn 180ms ease', }, modalCard: { width: '100%', maxWidth: 340, borderRadius: 24, background: '#FFFCF8', border: '1px solid #EEDFD0', boxShadow: '0 20px 48px rgba(20,18,14,0.18)', padding: '22px 18px 18px', position: 'relative', animation: 'slideUp 220ms cubic-bezier(.2,.8,.2,1)', }, modalClose: { position: 'absolute', top: 14, right: 14, width: 34, height: 34, borderRadius: 17, border: 'none', background: '#F4EADB', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', }, modalBadge: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: '4px 10px', borderRadius: 999, background: '#FBEFE6', color: '#A85D37', fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', marginBottom: 10, }, modalTitle: { fontSize: 22, fontWeight: 800, color: '#1A1A1A', lineHeight: 1.2, letterSpacing: '0.01em', }, modalIdWrap: { marginTop: 12, display: 'flex', alignItems: 'baseline', gap: 8, padding: '12px 14px', background: '#FFFFFF', borderRadius: 16, border: '1px solid #EEDFD0', }, modalIdLabel: { fontSize: 12, color: '#8A7F72', fontWeight: 600 }, modalIdValue: { fontSize: 24, color: '#C6572B', fontWeight: 800, fontFamily: '"Inter", "Noto Sans JP", sans-serif', letterSpacing: '0.01em', }, modalQrFrame: { marginTop: 14, background: '#FFFFFF', borderRadius: 20, border: '1px solid #EEDFD0', padding: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', }, modalQrImg: { width: '100%', maxWidth: 250, maxHeight: 330, objectFit: 'contain', display: 'block', borderRadius: 12, }, modalHint: { marginTop: 12, fontSize: 12, color: '#8A7F72', lineHeight: 1.6, textAlign: 'center', }, modalSaveBtn: { width: '100%', marginTop: 14, padding: '14px 16px', borderRadius: 16, border: 'none', background: '#1A1A1A', color: '#FFFFFF', fontSize: 15, fontWeight: 700, fontFamily: 'inherit', cursor: 'pointer', }, }; Object.assign(window, { RoomDetail });