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

201 lines
8.4 KiB
TypeScript

import { PLACE_ABOUT_FIELDS, placeRequestedLanguage } from './placeUtils';
import { pickFormattedPhoneNumber, pickPlaceWebsite, type GooglePlaceContactFields } from '../types';
import { extractPlaceAboutData } from './placeUtils';
import { getLocale } from '../locale';
import { cachePlaceDetail, getPlaceDetailOffline, getOfflinePlaceRowName, type OfflinePlaceRow } from './offlinePlaceStore';
import { buildPlaceDetailResultFromOfflineRow } from './placeDetailFromOffline';
import { readLatLngLike } from './latLng';
import { hasGoogleMapsApiKey } from './googleMapsConfig';
/** 将 JS Place.fetchFields 结果序列化为与 REST GET / 快照 `details` 一致的缓存结构 */
function buildOfflinePlaceRowFromFetchedPlace(
place: google.maps.places.Place & Record<string, unknown>,
): OfflinePlaceRow {
const loc = place.location;
if (!loc) {
throw new Error('place.location missing');
}
const lat = loc.lat();
const lng = loc.lng();
const details: Record<string, unknown> = {
displayName: place.displayName,
formattedAddress: place.formattedAddress,
rating: place.rating,
userRatingCount: place.userRatingCount,
businessStatus: place.businessStatus,
types: place.types,
primaryType: place.primaryType,
primaryTypeDisplayName: place.primaryTypeDisplayName,
internationalPhoneNumber: place.internationalPhoneNumber,
nationalPhoneNumber: place.nationalPhoneNumber,
websiteUri: place.websiteUri,
regularOpeningHours: place.regularOpeningHours,
currentOpeningHours: place.currentOpeningHours,
editorialSummary: place.editorialSummary,
plusCode: place.plusCode,
accessibilityOptions: place.accessibilityOptions,
paymentOptions: place.paymentOptions,
parkingOptions: place.parkingOptions,
hasDineIn: place.hasDineIn,
hasTakeout: place.hasTakeout,
hasDelivery: place.hasDelivery,
isReservable: place.isReservable,
servesBreakfast: place.servesBreakfast,
servesLunch: place.servesLunch,
servesDinner: place.servesDinner,
servesBrunch: place.servesBrunch,
servesBeer: place.servesBeer,
servesWine: place.servesWine,
servesCocktails: place.servesCocktails,
servesCoffee: place.servesCoffee,
servesDessert: place.servesDessert,
servesVegetarianFood: place.servesVegetarianFood,
hasRestroom: place.hasRestroom,
allowsDogs: place.allowsDogs,
hasOutdoorSeating: place.hasOutdoorSeating,
hasLiveMusic: place.hasLiveMusic,
isGoodForWatchingSports: place.isGoodForWatchingSports,
isGoodForChildren: place.isGoodForChildren,
isGoodForGroups: place.isGoodForGroups,
hasMenuForChildren: place.hasMenuForChildren,
};
return {
placeId: place.id as string,
name: String(place.displayName ?? ''),
lat,
lng,
rating: place.rating ?? undefined,
userRatingCount: place.userRatingCount ?? undefined,
types: (place.types as string[]) || [],
primaryType: place.primaryType as string | undefined,
formattedAddress: place.formattedAddress || '',
internationalPhoneNumber: place.internationalPhoneNumber as string | undefined,
details,
};
}
function buildPlaceDetailResultFromFetchedPlace(
place: google.maps.places.Place & Record<string, unknown>,
): Record<string, unknown> {
return {
place_id: place.id,
name: place.displayName,
formatted_address: place.formattedAddress,
geometry: { location: place.location },
rating: place.rating,
user_ratings_total: place.userRatingCount,
business_status: place.businessStatus,
types: place.types,
primaryType: place.primaryType,
primaryTypeDisplayName: place.primaryTypeDisplayName,
formatted_phone_number:
(place as { nationalPhoneNumber?: string }).nationalPhoneNumber ||
pickFormattedPhoneNumber(place as GooglePlaceContactFields),
websiteURI: pickPlaceWebsite(place as GooglePlaceContactFields),
regularOpeningHours:
place.regularOpeningHours || (place as { currentOpeningHours?: unknown }).currentOpeningHours || null,
editorialSummary: (place as { editorialSummary?: unknown }).editorialSummary,
plusCode: (place as { plusCode?: { compoundCode?: string } }).plusCode?.compoundCode || null,
_aboutData: extractPlaceAboutData(place),
};
}
type PlaceDetailFallback = {
name?: string;
formattedAddress?: string;
location?: google.maps.LatLng | google.maps.LatLngLiteral | null;
types?: string[];
};
function buildPlaceDetailFallbackResult(
placeId: string,
fallback: PlaceDetailFallback | undefined,
): Record<string, unknown> | null {
if (!fallback) return null;
const latLng = readLatLngLike(fallback.location);
const name = fallback.name?.trim() || '';
if (!name && !latLng) return null;
return {
place_id: placeId,
name,
formatted_address: fallback.formattedAddress || '',
geometry: latLng ? { location: latLng } : undefined,
types: fallback.types || [],
_aboutData: extractPlaceAboutData({}),
};
}
/**
* 离线快照优先;无 key 或 SDK 详情失败时,尽量用调用方提供的点击/列表上下文兜底。
* 当前 SW 快照覆盖 Maps JS/resources,不覆盖 places.googleapis.com 的详情 RPC。
*/
export async function fetchPlaceDetailWithOfflineFirst(options: {
placeId: string;
google?: typeof google | null;
fallback?: PlaceDetailFallback;
}): Promise<Record<string, unknown>> {
const { placeId, google, fallback } = options;
const locale = getLocale();
const offlineRow = await getPlaceDetailOffline(placeId);
if (offlineRow) {
const fromOffline = buildPlaceDetailResultFromOfflineRow(offlineRow, google, locale);
if (fromOffline) {
const hitKind = offlineRow.details ? '详情' : '基础信息';
console.log(`[Map][离线] 地点${hitKind}: "${getOfflinePlaceRowName(offlineRow, locale)}" (${placeId.slice(0, 16)}...)`);
return fromOffline as Record<string, unknown>;
}
}
if (!google) {
const fromFallback = buildPlaceDetailFallbackResult(placeId, fallback);
if (fromFallback) {
console.log(`[Map][离线未命中] 地点详情: ${placeId.slice(0, 16)}... Google SDK 不可用,使用点击上下文兜底`);
return fromFallback;
}
console.log(`[Map][离线未命中] 地点详情: ${placeId.slice(0, 16)}... 未命中离线详情/兜底,Google SDK 不可用`);
throw new Error(`Place detail missing from offline snapshot and Google Maps SDK is unavailable: ${placeId}`);
}
if (!hasGoogleMapsApiKey()) {
const fromFallback = buildPlaceDetailFallbackResult(placeId, fallback);
if (fromFallback) {
console.log(`[Map][离线未命中] 地点详情: ${placeId.slice(0, 16)}... 未配置 key,跳过 Places 详情 RPC,使用点击上下文兜底`);
return fromFallback;
}
console.log(`[Map][离线未命中] 地点详情: ${placeId.slice(0, 16)}... 未配置 key,跳过 Places 详情 RPC`);
throw new Error(`Place detail missing from offline snapshot and Google Maps key is unavailable: ${placeId}`);
}
// 当前 SW 快照覆盖 Maps JS/resources,不覆盖 places.googleapis.com 详情 RPC。
// 因此只有配置真实 key 时才进入 Place.fetchFields;无 key 离线未命中在上面降级。
console.log(`[Map][SDK] 地点详情: ${placeId.slice(0, 16)}... 离线快照未命中,使用真实 key 调用 Place.fetchFields`);
const { Place } = (await google.maps.importLibrary('places')) as google.maps.PlacesLibrary;
const place = new Place({ id: placeId, ...placeRequestedLanguage(locale) });
try {
await place.fetchFields({ fields: PLACE_ABOUT_FIELDS });
const p = place as google.maps.places.Place & Record<string, unknown>;
try {
cachePlaceDetail(buildOfflinePlaceRowFromFetchedPlace(p));
} catch {
/* 缓存失败不影响主流程 */
}
return buildPlaceDetailResultFromFetchedPlace(p);
} catch (error) {
const fromFallback = buildPlaceDetailFallbackResult(placeId, fallback);
if (fromFallback) {
console.log(`[Map][离线未命中] 地点详情: ${placeId.slice(0, 16)}... SDK 详情请求失败,使用点击上下文兜底`);
return fromFallback;
}
throw error;
}
}
export { buildPlaceDetailResultFromFetchedPlace };
/** 从详情结果读取坐标(兼容 LatLng 与离线构造) */
export function getLatLngFromPlaceDetailResult(
result: Record<string, unknown>,
): { lat: number; lng: number } | null {
const g = result.geometry as { location?: google.maps.LatLng | google.maps.LatLngLiteral } | undefined;
return readLatLngLike(g?.location);
}