const { useState: useStateRecommend, useEffect: useEffectRecommend, useMemo: useMemoRecommend, useRef: useRefRecommend } = React; const RECOMMEND_FLOOR_PLAN_OPTIONS = ['1R', '1K', '1DK', '1LDK', '2K', '2DK', '2LDK', '3K', '3DK', '3LDK', '4K', '4DK', '4LDK']; const RECOMMEND_DEFAULT_VISIBLE_COUNT = 5; const RECOMMEND_RENT_MIN = 40000; const RECOMMEND_RENT_MAX = 500000; const RECOMMEND_RENT_STEP = 5000; const RECOMMEND_CONTACT_METHODS = [ { key: 'wechat', label: '微信号' }, { key: 'phone', label: '手机号码' }, ]; const RECOMMEND_LEAFLET_JS = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js'; const RECOMMEND_LEAFLET_CSS = 'https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css'; let recommendLeafletPromise = null; function createRecommendFilters(regionKey = '') { return { contactMethod: 'wechat', contactValue: '', regionKey: regionKey || '', areaKey: '', districtName: '', operatorName: '', lineId: '', station: '', keyword: '', minRent: RECOMMEND_RENT_MIN, maxRent: RECOMMEND_RENT_MAX, floorPlans: [], vacancyOnly: false, }; } function normalizeRecommendText(value) { return String(value || '').trim().toLowerCase(); } function loadRecommendLeaflet() { if (window.L) return Promise.resolve(window.L); if (recommendLeafletPromise) return recommendLeafletPromise; recommendLeafletPromise = new Promise((resolve, reject) => { if (!document.querySelector(`link[href="${RECOMMEND_LEAFLET_CSS}"]`)) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = RECOMMEND_LEAFLET_CSS; document.head.appendChild(link); } const existing = document.querySelector(`script[src="${RECOMMEND_LEAFLET_JS}"]`); if (existing) { existing.addEventListener('load', () => resolve(window.L)); existing.addEventListener('error', reject); return; } const script = document.createElement('script'); script.src = RECOMMEND_LEAFLET_JS; script.async = true; script.onload = () => resolve(window.L); script.onerror = reject; document.head.appendChild(script); }); return recommendLeafletPromise; } function extractRecommendFloorPlanTokens(value) { return (String(value || '').toUpperCase().match(/\d+(?:R|K|DK|LDK)/g) || []); } function recommendFloorPlanScore(value) { return RECOMMEND_FLOOR_PLAN_OPTIONS.indexOf(String(value || '').toUpperCase()); } function recommendPropertyMatchesFloorPlans(floorPlanText, selectedPlans) { if (!selectedPlans?.length) return true; const normalized = String(floorPlanText || '').toUpperCase().replace(/\s+/g, ''); if (!normalized) return false; if (selectedPlans.some((plan) => normalized.includes(String(plan).toUpperCase()))) { return true; } const rangeText = normalized.split('/')[0] || normalized; const tokens = extractRecommendFloorPlanTokens(rangeText); if (tokens.length >= 2 && rangeText.includes('~')) { const start = recommendFloorPlanScore(tokens[0]); const end = recommendFloorPlanScore(tokens[tokens.length - 1]); if (start >= 0 && end >= 0) { const min = Math.min(start, end); const max = Math.max(start, end); return selectedPlans.some((plan) => { const score = recommendFloorPlanScore(plan); return score >= min && score <= max; }); } } return false; } function recommendPropertyMatchesStation(property, stationName) { const normalizedStation = normalizeRecommendText(stationName); if (!normalizedStation) return true; return (property.stations || []).some((item) => normalizeRecommendText(item) === normalizedStation) || normalizeRecommendText(property.accessText).includes(normalizedStation) || normalizeRecommendText(property.detailTransportText).includes(normalizedStation); } function recommendPropertyMatchesLine(property, line) { if (!line) return true; const normalizedLineName = normalizeRecommendText(line.lineName); if (!normalizedLineName) return true; if ( normalizeRecommendText(property.accessText).includes(normalizedLineName) || normalizeRecommendText(property.detailTransportText).includes(normalizedLineName) ) { return true; } const propertyStationSet = new Set((property.stations || []).map((item) => normalizeRecommendText(item)).filter(Boolean)); return (line.stations || []).some((station) => propertyStationSet.has(normalizeRecommendText(station.stationName))); } function recommendPropertyMatchesOperator(property, operator) { if (!operator?.lines?.length) return true; return operator.lines.some((line) => recommendPropertyMatchesLine(property, line)); } function parseRecommendRentRange(value) { return parseRentRange(String(value || '').replace(/[((][^))]*[))]/g, '').trim()); } function formatRecommendManYen(value) { const amount = Number(value || 0); if (!Number.isFinite(amount) || amount <= 0) return '0万'; const man = amount / 10000; if (man < 10) return `${man.toFixed(1)}万`; return Number.isInteger(man) ? `${man}万` : `${man.toFixed(1)}万`; } function filterRecommendProperties(properties, filters, detailedOperators, stationDirectory) { if (!filters?.regionKey) return []; const currentOperator = detailedOperators.find((item) => item.operatorName === filters.operatorName) || null; const currentLine = (stationDirectory?.lines || []).find((item) => item.lineId === filters.lineId) || null; const keyword = normalizeRecommendText(filters.keyword); return properties .filter((item) => { if (filters.areaKey && item.areaKey !== filters.areaKey) return false; if (filters.districtName && normalizeDistrictName(item.districtName || item.skcs) !== filters.districtName) return false; if (filters.operatorName && currentOperator && !recommendPropertyMatchesOperator(item, currentOperator)) return false; if (filters.lineId && currentLine && !recommendPropertyMatchesLine(item, currentLine)) return false; if (filters.station && !recommendPropertyMatchesStation(item, filters.station)) return false; const rentRange = parseRecommendRentRange(item.detailRentText || item.rent || item.rent); if (rentRange.max < Number(filters.minRent)) return false; if (rentRange.min > Number(filters.maxRent)) return false; if (filters.vacancyOnly && !item.hasVacancy) return false; if (!recommendPropertyMatchesFloorPlans(item.floorPlanText, filters.floorPlans)) return false; if (keyword) { const haystack = [ item.name, item.districtName, item.skcs, item.areaName, item.address, item.propertyKey, item.accessText, item.detailTransportText, ...(item.stations || []), ]; if (!haystack.some((value) => normalizeRecommendText(value).includes(keyword))) { return false; } } return true; }) .sort((left, right) => parseRecommendRentRange(left.detailRentText || left.rent).min - parseRecommendRentRange(right.detailRentText || right.rent).min); } function RecommendScreen({ initialRegionKey = '', onOpenListingRoom, onOpenWatchProperty, stateSnapshot, onStateChange }) { const initialState = stateSnapshot || { prompt: '', answer: '', filters: null, properties: [], resultCount: 0, visibleCount: RECOMMEND_DEFAULT_VISIBLE_COUNT, resultView: 'list', mapLocations: [], scrollTop: 0, anchorPropertyKey: '', anchorRestorePending: false, }; const [prompt, setPrompt] = useStateRecommend(() => initialState.prompt || ''); const [answer, setAnswer] = useStateRecommend(() => initialState.answer || ''); const [filters, setFilters] = useStateRecommend(() => initialState.filters || null); const [properties, setProperties] = useStateRecommend(() => initialState.properties || []); const [resultCount, setResultCount] = useStateRecommend(() => Number(initialState.resultCount || 0) || 0); const [visibleCount, setVisibleCount] = useStateRecommend(() => Number(initialState.visibleCount) || RECOMMEND_DEFAULT_VISIBLE_COUNT); const [resultView, setResultView] = useStateRecommend(() => initialState.resultView === 'map' ? 'map' : 'list'); const [mapLocations, setMapLocations] = useStateRecommend(() => Array.isArray(initialState.mapLocations) ? initialState.mapLocations : []); const [mapLoading, setMapLoading] = useStateRecommend(false); const [mapError, setMapError] = useStateRecommend(''); const [loading, setLoading] = useStateRecommend(false); const [error, setError] = useStateRecommend(''); const scrollRef = useRefRecommend(null); const restoredScrollRef = useRefRecommend(false); const latestStateRef = useRefRecommend(initialState); const visibleProperties = properties.slice(0, visibleCount); const canLoadMore = properties.length > visibleCount; useEffectRecommend(() => { latestStateRef.current = { prompt, answer, filters, properties, resultCount, visibleCount, resultView, mapLocations, scrollTop: scrollRef.current?.scrollTop || 0, anchorPropertyKey: latestStateRef.current?.anchorPropertyKey || '', anchorRestorePending: latestStateRef.current?.anchorRestorePending || false, }; onStateChange && onStateChange(latestStateRef.current); }, [answer, filters, mapLocations, onStateChange, prompt, properties, resultCount, resultView, visibleCount]); useEffectRecommend(() => { if (restoredScrollRef.current || stateSnapshot?.anchorRestorePending) return; restoredScrollRef.current = true; const nextScrollTop = Number(stateSnapshot?.scrollTop || 0); if (!nextScrollTop) return; window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { if (scrollRef.current) scrollRef.current.scrollTop = nextScrollTop; }); }); }, []); useEffectRecommend(() => ( () => { if (!onStateChange) return; const current = latestStateRef.current || {}; onStateChange({ ...current, scrollTop: scrollRef.current?.scrollTop || 0, }); } ), [onStateChange]); useEffectRecommend(() => { if (!stateSnapshot?.anchorRestorePending) return; const targetKey = stateSnapshot.anchorPropertyKey; if (!targetKey || !scrollRef.current) return; const targetElement = scrollRef.current.querySelector(`[data-recommend-card-key="${targetKey}"]`); if (!targetElement) return; const container = scrollRef.current; const rawTop = targetElement.offsetTop - Math.max(0, (container.clientHeight - targetElement.offsetHeight) / 2); const maxTop = Math.max(0, container.scrollHeight - container.clientHeight); const nextTop = Math.max(0, Math.min(rawTop, maxTop)); window.requestAnimationFrame(() => { container.scrollTop = nextTop; const nextSnapshot = { ...latestStateRef.current, scrollTop: nextTop, anchorRestorePending: false, }; latestStateRef.current = nextSnapshot; onStateChange && onStateChange(nextSnapshot); }); }, [onStateChange, stateSnapshot?.anchorPropertyKey, stateSnapshot?.anchorRestorePending, visibleProperties.length]); function rememberRecommendState(anchorPropertyKey = '') { const snapshot = { prompt, answer, filters, properties, resultCount, visibleCount, resultView, mapLocations, scrollTop: scrollRef.current?.scrollTop || 0, anchorPropertyKey, anchorRestorePending: Boolean(anchorPropertyKey), }; latestStateRef.current = snapshot; onStateChange && onStateChange(snapshot); } async function handleSubmit() { const nextPrompt = String(prompt || '').trim(); if (!nextPrompt) { setError('请先输入你的找房需求。'); return; } if (nextPrompt.length > 200) { setError('需求最多输入 200 个字。'); return; } setError(''); setAnswer(''); setFilters(null); setProperties([]); setResultCount(0); setVisibleCount(RECOMMEND_DEFAULT_VISIBLE_COUNT); setResultView('list'); setMapLocations([]); setMapError(''); setLoading(true); try { const result = await subscribeApiFetch('/ai-recommend', { method: 'POST', body: { prompt: nextPrompt }, }); setAnswer(result.answer || ''); setFilters(result.filters || null); setProperties(Array.isArray(result.properties) ? result.properties : []); setResultCount(Number(result.resultCount || 0) || 0); setVisibleCount(RECOMMEND_DEFAULT_VISIBLE_COUNT); setResultView('list'); setMapLocations([]); setMapError(''); } catch (issue) { setAnswer(''); setFilters(null); setProperties([]); setResultCount(0); setResultView('list'); setMapLocations([]); setMapError(''); setError(issue.message === 'rate_limited' ? '请求有点频繁,请稍后再试。' : 'AI 荐房暂时没有成功,请稍后再试。'); } finally { setLoading(false); } } function resetDraft() { setPrompt(''); setAnswer(''); setFilters(null); setProperties([]); setResultCount(0); setVisibleCount(RECOMMEND_DEFAULT_VISIBLE_COUNT); setResultView('list'); setMapLocations([]); setMapError(''); setError(''); } async function openMapView() { setResultView('map'); if (!properties.length || mapLocations.length > 0 || mapLoading) return; setMapError(''); setMapLoading(true); try { const result = await subscribeApiFetch('/map-locations', { method: 'POST', body: { properties: properties.slice(0, 20).map((property) => ({ propertyKey: property.propertyKey, name: property.name, regionName: property.regionName, districtName: property.districtName || property.skcs || property.areaName, skcs: property.skcs, areaName: property.areaName, address: property.address, rent: property.rent, detailRentText: property.detailRentText, hasVacancy: property.hasVacancy, activeRoomCount: property.activeRoomCount, roomCount: property.roomCount, rooms: property.rooms || [], })), }, }); setMapLocations(Array.isArray(result.locations) ? result.locations : []); } catch (issue) { setMapError(issue.message === 'rate_limited' ? '请求有点频繁,请稍后再试。' : '地图位置暂时加载失败,请稍后再试。'); } finally { setMapLoading(false); } } function useExample(text) { setPrompt(text); setError(''); } const promptLength = String(prompt || '').length; return (
{ latestStateRef.current = { prompt, answer, filters, properties, resultCount, visibleCount, resultView, mapLocations, scrollTop: scrollRef.current?.scrollTop || 0, }; }} >
AI 荐房