// UR租房助手 — main screen, API-driven const { useState: useStateMain, useEffect: useEffectMain, useMemo: useMemoMain, useCallback: useCallbackMain } = React; const BRAND_LOGO_URL = '/assets/logo-urrent.png'; const STATIC_REGION_LANDING_KEYS = new Set(['tokyo', 'osaka', 'fukuoka']); const REGION_SEO_NAMES = { hokkaido: '北海道', miyagi: '宫城县', tokyo: '东京', kanagawa: '神奈川', chiba: '千叶', saitama: '埼玉', ibaraki: '茨城', aichi: '爱知', mie: '三重', gifu: '岐阜', osaka: '大阪', hyogo: '兵库', kyoto: '京都', shiga: '滋贺', nara: '奈良', wakayama: '和歌山', okayama: '冈山', hiroshima: '广岛', yamaguchi: '山口', fukuoka: '福冈', }; const DEFAULT_REGION_KEY = 'tokyo'; function getInitialLocationFilter() { const fallback = { regions: [DEFAULT_REGION_KEY], areas: [] }; if (typeof window === 'undefined') return fallback; const validRegionKeys = new Set(REGIONS.flatMap(group => group.prefs.map(pref => pref.key))); const pathRegion = window.location.pathname.replace(/^\/+|\/+$/g, ''); if (pathRegion && validRegionKeys.has(pathRegion)) { return { regions: [pathRegion], areas: [] }; } const params = new URLSearchParams(window.location.search); const regionParam = params.get('region'); if (!regionParam) return fallback; const regions = regionParam .split(',') .map(item => item.trim()) .filter(item => validRegionKeys.has(item)); return regions.length > 0 ? { regions, areas: [] } : fallback; } function setMetaContent(selector, content) { const node = document.querySelector(selector); if (node && content) node.setAttribute('content', content); } function setLinkHref(selector, href) { const node = document.querySelector(selector); if (node && href) node.setAttribute('href', href); } function buildSeoPayload(selectedKeys) { const regions = selectedKeys.length > 0 ? selectedKeys : [DEFAULT_REGION_KEY]; if (regions.length === 1) { const regionKey = regions[0]; const regionName = REGION_SEO_NAMES[regionKey] || regionKey; return { title: `${regionName}UR租房 | UR租房帮手|日本UR公寓中文查询`, description: `查看${regionName}UR租房房源,支持按地区、租金和户型筛选,浏览户型图、物件照片与房间详情,帮助华人用户更高效找到合适的日本UR公寓。`, }; } return { title: '日本UR租房 | UR租房帮手|东京·大阪·福冈等地区UR公寓查询', description: 'UR租房帮手为华人用户提供日本UR租房信息查询,覆盖东京、大阪、福冈等地区,支持按地区、租金、房型筛选,查看房间详情、户型图与物件照片。', }; } function buildCanonicalUrl(selectedKeys) { const base = new URL(window.location.origin); if (selectedKeys.length === 1 && STATIC_REGION_LANDING_KEYS.has(selectedKeys[0])) { base.pathname = `/${selectedKeys[0]}/`; return base.toString(); } base.pathname = '/'; if (selectedKeys.length === 1) { base.searchParams.set('region', selectedKeys[0]); } else if (selectedKeys.length > 1) { base.searchParams.set('region', selectedKeys.join(',')); } return base.toString(); } function App() { const [tab, setTab] = useStateMain('listings'); const [locSheetOpen, setLocSheetOpen] = useStateMain(false); const [rentSheetOpen, setRentSheetOpen] = useStateMain(false); const [districtSheetOpen, setDistrictSheetOpen] = useStateMain(false); const [locationFilter, setLocationFilter] = useStateMain(getInitialLocationFilter); const [selectedDistricts, setSelectedDistricts] = useStateMain([]); const [rent, setRent] = useStateMain({ min: 0, max: MAX_RENT }); const [sort, setSort] = useStateMain('recommend'); const [areasByRegion, setAreasByRegion] = useStateMain({}); const [showRefreshHint, setShowRefreshHint] = useStateMain(false); const [properties, setProperties] = useStateMain([]); const [loading, setLoading] = useStateMain(false); const [error, setError] = useStateMain(null); const [activeRoom, setActiveRoom] = useStateMain(null); // { property, room } const openRoomByKeys = useCallbackMain(async (propertyKey, roomKey) => { if (!propertyKey || !roomKey) return; try { const property = await apiFetch('/api/v1/properties/' + encodeURIComponent(propertyKey)); const rooms = property.rooms || []; const room = rooms.find((item) => item.roomKey === roomKey || item.id === roomKey || item.canonicalRoomId === roomKey); if (!room) return; setTab('listings'); setActiveRoom({ property, room }); } catch (err) { console.warn('open room by keys failed', err); } }, []); const selectedKeys = locationFilter.regions; const selectedAreaKeys = locationFilter.areas; const ensureAreas = useCallbackMain(async (regionKey) => { if (!regionKey) return; const current = areasByRegion[regionKey]; if (current?.loading || current?.loaded) return; setAreasByRegion(prev => ({ ...prev, [regionKey]: { data: prev[regionKey]?.data || [], loading: true, loaded: false, error: null, }, })); try { const res = await apiFetch('/api/v1/areas?regionKey=' + encodeURIComponent(regionKey)); const data = Array.isArray(res) ? res : (res.data || res.areas || res.items || []); setAreasByRegion(prev => ({ ...prev, [regionKey]: { data, loading: false, loaded: true, error: null, }, })); } catch (err) { setAreasByRegion(prev => ({ ...prev, [regionKey]: { data: prev[regionKey]?.data || [], loading: false, loaded: false, error: err.message || '区域加载失败', }, })); } }, [areasByRegion]); useEffectMain(() => { selectedKeys.forEach(ensureAreas); }, [selectedKeys, ensureAreas]); const loadProperties = useCallbackMain(async () => { if (selectedKeys.length === 0) { setProperties([]); return; } setLoading(true); setError(null); try { const results = await Promise.all( selectedKeys.map(k => apiFetch('/api/v1/properties?regionKey=' + encodeURIComponent(k)) .catch(err => { console.warn('region ' + k + ' failed', err); return null; })) ); const merged = []; const seen = new Set(); let anyOk = false; for (const res of results) { if (!res) continue; anyOk = true; const arr = Array.isArray(res) ? res : (res.data || res.properties || res.items || []); for (const p of arr) { const key = p.propertyKey || p.id; if (seen.has(key)) continue; seen.add(key); merged.push(p); } } if (!anyOk) throw new Error('API 无法从浏览器访问(CORS 未开启)。请在 api.repilot.jp 添加 Access-Control-Allow-Origin 响应头。'); setProperties(merged); return true; } catch (e) { setError(e.message || '加载失败'); setProperties([]); return false; } finally { setLoading(false); } }, [selectedKeys]); useEffectMain(() => { loadProperties(); }, [loadProperties]); useEffectMain(() => { if (!showRefreshHint) return undefined; const timer = window.setTimeout(() => setShowRefreshHint(false), 1800); return () => window.clearTimeout(timer); }, [showRefreshHint]); useEffectMain(() => { const params = new URLSearchParams(window.location.search); const propertyKey = params.get('propertyKey'); const roomKey = params.get('roomKey'); const open = params.get('open'); if (open === 'room' && propertyKey && roomKey) { openRoomByKeys(propertyKey, roomKey); } }, [openRoomByKeys]); useEffectMain(() => { const normalizedRegions = selectedKeys.length > 0 ? selectedKeys : [DEFAULT_REGION_KEY]; const payload = buildSeoPayload(normalizedRegions); const canonicalUrl = buildCanonicalUrl(selectedKeys); document.title = payload.title; setMetaContent('meta[name="description"]', payload.description); setMetaContent('meta[property="og:title"]', payload.title); setMetaContent('meta[property="og:description"]', payload.description); setMetaContent('meta[property="og:url"]', canonicalUrl); setMetaContent('meta[name="twitter:title"]', payload.title); setMetaContent('meta[name="twitter:description"]', payload.description); setLinkHref('link[rel="canonical"]', canonicalUrl); window.history.replaceState({}, '', canonicalUrl); }, [selectedKeys]); const handleRefresh = useCallbackMain(async () => { const ok = await loadProperties(); if (ok) setShowRefreshHint(true); }, [loadProperties]); const filtered = useMemoMain(() => { let list = properties.filter(p => { const parsed = parseRentRange(p.rent); // property matches if its min rent ≤ rent.max and max rent ≥ rent.min if (parsed.max < rent.min) return false; if (parsed.min > rent.max) return false; if (selectedAreaKeys.length > 0 && !selectedAreaKeys.includes(p.areaKey)) return false; if (selectedDistricts.length > 0 && !selectedDistricts.includes(normalizeDistrictName(p.skcs || p.districtName))) return false; return (p.activeRoomCount ?? (p.rooms || []).length) > 0; }); if (sort === 'cheap') list.sort((a, b) => parseRentRange(a.rent).min - parseRentRange(b.rent).min); if (sort === 'expensive') list.sort((a, b) => parseRentRange(b.rent).max - parseRentRange(a.rent).max); return list; }, [properties, rent, sort, selectedAreaKeys, selectedDistricts]); const totalRooms = filtered.reduce((s, p) => s + (p.activeRoomCount || 0), 0); const districtOptions = useMemoMain(() => ( selectedAreaKeys.length > 0 ? buildDistrictOptions( properties, selectedAreaKeys, areasByRegion, (property) => property.skcs || property.districtName || '' ) : [] ), [properties, selectedAreaKeys, areasByRegion]); useEffectMain(() => { const validDistricts = new Set(districtOptions.map((item) => item.key)); setSelectedDistricts((prev) => { const next = prev.filter((item) => validDistricts.has(item)); return next.length === prev.length ? prev : next; }); }, [districtOptions]); const labelOf = (key) => { for (const r of REGIONS) { const m = r.prefs.find(p => p.key === key); if (m) return m.label; } return key; }; const areaLabelOf = (areaKey) => { const regionKey = areaKey.split('__')[0]; const area = areasByRegion[regionKey]?.data?.find(item => item.areaKey === areaKey); return area?.name || areaKey; }; const locLabel = selectedAreaKeys.length > 0 ? selectedAreaKeys.length === 1 ? areaLabelOf(selectedAreaKeys[0]) : `${areaLabelOf(selectedAreaKeys[0])} 等${selectedAreaKeys.length}个片区` : selectedKeys.length === 0 ? '位置' : selectedKeys.length === 1 ? labelOf(selectedKeys[0]) : `${labelOf(selectedKeys[0])} 等${selectedKeys.length}个`; const rentLabel = (rent.min === 0 && rent.max === MAX_RENT) ? '租金' : rent.max >= MAX_RENT ? `¥${manYen(rent.min)}〜` : rent.min === 0 ? `〜¥${manYen(rent.max)}` : `¥${manYen(rent.min)}〜${manYen(rent.max)}`; const districtLabel = selectedDistricts.length > 0 ? selectedDistricts.length === 1 ? selectedDistricts[0] : `${selectedDistricts[0]} 等${selectedDistricts.length}个区域` : '区域'; return (