Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

297 lines
9.7 KiB
TypeScript

import { getPlaceTypeLabel } from '../constants';
import type { ShoppingItem } from '../types';
import { getDate as timeGetDate } from '../../../os/TimeService';
import { getLocale } from '../locale';
import { computeOpeningStatusFromPeriods, formatDistanceLabelMeters, googleLangCode } from './placeUtils';
import { cachePlaceResults, parseOfflinePageToken, searchPlacesOffline } from './offlinePlaceStore';
import { getGoogleMapsApiKey } from './googleMapsConfig';
import { makeLatLngLike } from './latLng';
export interface PlaceSearchResult {
placeId: string;
name: string;
lat: number;
lng: number;
rating?: number;
userRatingCount?: number;
types: string[];
primaryType?: string;
primaryTypeDisplayName?: string;
businessStatus?: string;
formattedAddress: string;
internationalPhoneNumber?: string;
openNow?: boolean;
closesAt?: string;
opensNextLabel?: string;
distanceMeters?: number;
}
export interface SearchPlacesByTextResult {
results: PlaceSearchResult[];
nextPageToken?: string;
}
interface SearchPlacesByTextOptions {
textQuery: string;
/** 用于 Places locationBias,决定搜索哪一片区域。 */
searchCenter?: { lat: number; lng: number } | null;
/** 用于结果展示距离,通常固定为当前位置。 */
distanceOrigin?: { lat: number; lng: number } | null;
pageSize?: number;
pageToken?: string;
language?: string;
}
const SEARCH_FIELD_MASK = [
'places.id',
'places.displayName',
'places.location',
'places.rating',
'places.userRatingCount',
'places.types',
'places.primaryType',
'places.primaryTypeDisplayName',
'places.businessStatus',
'places.formattedAddress',
'places.internationalPhoneNumber',
'places.regularOpeningHours',
'nextPageToken',
].join(',');
function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371000;
const toRad = (deg: number) => deg * (Math.PI / 180);
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export async function searchPlacesByText({
textQuery,
searchCenter,
distanceOrigin,
pageSize = 20,
pageToken,
language,
}: SearchPlacesByTextOptions): Promise<SearchPlacesByTextResult> {
const locale = getLocale();
const resolvedLanguage = language || googleLangCode(locale);
const effectiveDistanceOrigin = distanceOrigin ?? searchCenter;
// 没有 key = 没有在线 fallback。此时把离线 200m gate 放宽,跨快照区域也返回结果。
// 代价:类目类查询(餐厅/咖啡)在远视口下会拿到 snapshot 中心附近的"地理错位"结果;
// 但名字类查询(圆明园/故宫)依然正确,且整体上"有结果"比"完全空白"更可用。
const apiKey = getGoogleMapsApiKey();
const allowAcrossSnapshot = !apiKey;
const offlineOffset = pageToken ? parseOfflinePageToken(pageToken) : null;
if (!pageToken || offlineOffset !== null) {
const { results: offlineResults, nextPageToken: offlineNext } = await searchPlacesOffline(
textQuery,
searchCenter,
effectiveDistanceOrigin,
locale,
pageSize,
offlineOffset ?? 0,
{ allowAcrossSnapshot },
);
if (offlineResults.length > 0) {
const acrossNote = allowAcrossSnapshot ? '(跨快照区域)' : '';
console.log(
`[Map][离线] ${offlineOffset ? '文本搜索 loadMore' : '文本搜索'}${acrossNote}: "${textQuery}" -> ${offlineResults.length}${offlineNext ? ', hasMore' : ''}`,
);
return { results: offlineResults, nextPageToken: offlineNext };
}
}
if (!apiKey) {
console.log(`[Map][离线未命中] 文本搜索: "${textQuery}" 未命中本地快照,且未配置 Google Maps key,跳过在线搜索`);
return { results: [] };
}
console.log(
`[Map][在线] ${pageToken ? '文本搜索 loadMore' : '文本搜索'}: "${textQuery}" 离线未命中,调用 Places searchText`,
);
const body: Record<string, unknown> = {
textQuery,
languageCode: resolvedLanguage,
pageSize,
};
if (searchCenter) {
body.locationBias = {
circle: {
center: { latitude: searchCenter.lat, longitude: searchCenter.lng },
radius: 5000,
},
};
}
if (pageToken) body.pageToken = pageToken;
const resp = await fetch('https://places.googleapis.com/v1/places:searchText', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': SEARCH_FIELD_MASK,
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
throw new Error(
`Places API error: ${resp.status} ${resp.statusText}${text ? ` - ${text.slice(0, 200)}` : ''}`,
);
}
const data = await resp.json();
const places: any[] = data.places || [];
const now = timeGetDate();
const results = places
.filter((p: any) => p.id && p.location)
.map((p: any) => {
const lat: number = p.location.latitude;
const lng: number = p.location.longitude;
let distanceMeters: number | undefined;
if (effectiveDistanceOrigin) {
distanceMeters = haversineDistance(
effectiveDistanceOrigin.lat,
effectiveDistanceOrigin.lng,
lat,
lng,
);
}
const rawHrs = p.regularOpeningHours;
const hasPeriods = Array.isArray(rawHrs?.periods) && rawHrs.periods.length > 0;
const hoursStatus = hasPeriods ? computeOpeningStatusFromPeriods(rawHrs, now, locale) : null;
return {
placeId: p.id as string,
name: (p.displayName?.text as string) || '',
lat,
lng,
rating: p.rating ?? undefined,
userRatingCount: p.userRatingCount ?? undefined,
types: (p.types as string[]) || [],
primaryType: (p.primaryType as string) || undefined,
primaryTypeDisplayName: (p.primaryTypeDisplayName?.text as string) || undefined,
businessStatus: (p.businessStatus as string) || undefined,
formattedAddress: (p.formattedAddress as string) || '',
internationalPhoneNumber: (p.internationalPhoneNumber as string) || undefined,
openNow: hoursStatus?.isOpen,
closesAt: hoursStatus?.closesAt ?? undefined,
opensNextLabel: hoursStatus?.opensNextLabel ?? undefined,
distanceMeters,
} satisfies PlaceSearchResult;
})
.filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng));
console.log(
`[Map][在线] ${pageToken ? '文本搜索 loadMore' : '文本搜索'}: "${textQuery}" -> ${results.length}${data.nextPageToken ? ', hasMore' : ''}`,
);
if (import.meta.env.DEV) {
console.groupCollapsed(
`[Map][在线] "${textQuery}" results`,
);
console.table(
results.map((r, i) => ({
'#': i + 1,
name: r.name,
rating: r.rating ?? '-',
reviews: r.userRatingCount ?? '-',
dist: r.distanceMeters ? `${(r.distanceMeters / 1000).toFixed(1)}km` : '-',
type: r.primaryTypeDisplayName || r.primaryType || '-',
open: r.openNow === true ? '✓' : r.openNow === false ? '✗' : '-',
lat: r.lat.toFixed(4),
lng: r.lng.toFixed(4),
})),
);
console.groupEnd();
}
if (!pageToken && results.length > 0) {
const cacheKey = textQuery.trim();
if (cacheKey) cachePlaceResults(cacheKey, searchCenter, results);
}
return { results, nextPageToken: data.nextPageToken || undefined };
}
export function placeSearchResultToShoppingItem(
result: PlaceSearchResult,
options?: { categoryOverride?: string },
): ShoppingItem {
return {
id: result.placeId,
name: result.name,
rating: result.rating,
ratingCount: result.userRatingCount,
category:
options?.categoryOverride ??
getPlaceTypeLabel(result.types, result.primaryType, result.primaryTypeDisplayName, getLocale()),
types: result.types,
primaryType: result.primaryType,
distance: result.distanceMeters ?? 0,
address: result.formattedAddress,
status: result.businessStatus === 'OPERATIONAL' ? 'Open' : 'Closed',
openNow: result.openNow,
closesAt: result.closesAt,
opensNextLabel: result.opensNextLabel,
lat: result.lat,
lng: result.lng,
};
}
export function placeSearchResultToPlaceResult(
result: PlaceSearchResult,
): google.maps.places.PlaceResult {
return {
place_id: result.placeId,
name: result.name,
geometry: {
location: makeLatLngLike(result.lat, result.lng),
},
rating: result.rating,
user_ratings_total: result.userRatingCount,
types: result.types,
formatted_address: result.formattedAddress,
vicinity: result.formattedAddress,
business_status: result.businessStatus as google.maps.places.BusinessStatus | undefined,
};
}
export function placeSearchResultToSearchRecord(
result: PlaceSearchResult,
): Record<string, any> {
return {
place_id: result.placeId,
name: result.name,
rating: result.rating,
user_ratings_total: result.userRatingCount,
formatted_address: result.formattedAddress,
address: result.formattedAddress,
formatted_phone_number: result.internationalPhoneNumber,
distance_meters: result.distanceMeters,
distance: formatDistanceLabelMeters(result.distanceMeters, getLocale()),
types: result.types,
primary_type: result.primaryType,
primary_type_display_name: result.primaryTypeDisplayName,
category: getPlaceTypeLabel(result.types, result.primaryType, result.primaryTypeDisplayName, getLocale()),
business_status: result.businessStatus,
open_now: result.openNow,
closes_at: result.closesAt,
opens_next_label: result.opensNextLabel,
lat: result.lat,
lng: result.lng,
};
}