// 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 */}
{/* Gallery */}
{cur && !imgErr[idx] ? (

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) => (
))}
)}
{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 (
);
}
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 });