项目文件夹

文件
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

381 行
14 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import {
IcClock,
IcClose,
IcLocation,
IcMic,
IcNavBack,
} from '../../res/icons';
import { useLocale } from '../../locale';
import { googleLangCode } from '../../utils/placeUtils';
import { useMapBackHandler } from '../../hooks/useMapBackHandler';
import { useMapGestures } from '../../hooks/useMapGestures';
import { useMapStrings } from '../../hooks/useMapStrings';
import {
useMapStore,
selectCurrentLocation,
selectGoogle,
type MapSearchHistoryEntry,
} from '../../state';
import { formatDistanceLabelMeters } from '../../utils/placeUtils';
import { getAutocompleteOffline } from '../../utils/offlinePlaceStore';
import { hasGoogleMapsApiKey } from '../../utils/googleMapsConfig';
import { KeyboardService } from '@/os/keyboard';
type RouteSide = 'origin' | 'destination';
type PlacePrediction = {
placeId: string;
mainText: string;
secondaryText: string;
distanceMeters?: number;
};
const AUTOCOMPLETE_DEBOUNCE_MS = 280;
export const RoutePointPickerOverlay: React.FC<{
mode: RouteSide;
initialQuery?: string;
searchHistory: MapSearchHistoryEntry[];
counterpart: { name: string } | null;
continuationDuration?: string | null;
viewportCenter?: { lat: number; lng: number } | null;
onClose: () => void;
onChooseOnMap: (side: RouteSide) => void;
onSelectPlace: (placeId: string, title: string, subtitle?: string) => void;
}> = ({
mode,
initialQuery = '',
searchHistory,
counterpart,
continuationDuration,
viewportCenter,
onClose,
onChooseOnMap,
onSelectPlace,
}) => {
const { bindTap } = useMapGestures();
const google = useMapStore(selectGoogle);
const currentLocation = useMapStore(selectCurrentLocation);
const locale = useLocale();
const s = useMapStrings();
const [query, setQuery] = useState(initialQuery);
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery);
const [predictions, setPredictions] = useState<PlacePrediction[]>([]);
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useMapBackHandler(
() => {
onClose();
return true;
},
{ priority: 700 },
);
useEffect(() => {
const t = window.setTimeout(() => setDebouncedQuery(query), AUTOCOMPLETE_DEBOUNCE_MS);
return () => window.clearTimeout(t);
}, [query]);
useEffect(() => {
if (!google) return;
void google.maps.importLibrary('places');
}, [google]);
useEffect(() => {
const trimmedQuery = debouncedQuery.trim();
if (!trimmedQuery) {
setPredictions([]);
setSuggestionsLoading(false);
return;
}
let cancelled = false;
setSuggestionsLoading(true);
const run = async () => {
const offline = await getAutocompleteOffline({
input: trimmedQuery,
locale,
placesOnly: true,
});
if (cancelled) return;
if (offline.source === 'autocomplete_index') {
console.log(`[Map][离线] 路线选点提示: "${trimmedQuery}" 命中 autocomplete_index -> ${offline.predictions.length} 个地点`);
setPredictions(
offline.predictions.map((item) => ({
placeId: item.place_id,
mainText: item.main_text,
secondaryText: item.secondary_text,
distanceMeters: item.distance_meters,
})),
);
setSuggestionsLoading(false);
return;
}
if (!google || !hasGoogleMapsApiKey()) {
const fallback = await getAutocompleteOffline({
input: trimmedQuery,
locale,
placesOnly: true,
allowSearchIndexFallback: true,
});
if (cancelled) return;
if (fallback.predictions.length > 0) {
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 没有 autocomplete_index,Google key 不可用,使用 search_index 兜底 -> ${fallback.predictions.length} 个地点`);
setPredictions(
fallback.predictions.map((item) => ({
placeId: item.place_id,
mainText: item.main_text,
secondaryText: item.secondary_text,
distanceMeters: item.distance_meters,
})),
);
setSuggestionsLoading(false);
return;
}
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 未命中 autocomplete_index/search_index,Google key 不可用`);
setPredictions([]);
setSuggestionsLoading(false);
return;
}
console.log(`[Map][在线] 路线选点提示: "${trimmedQuery}" 离线未命中,调用 Google Place Autocomplete`);
const service = new google.maps.places.AutocompleteService();
const biasLat = viewportCenter?.lat ?? currentLocation.latitude;
const biasLng = viewportCenter?.lng ?? currentLocation.longitude;
const locationForBias =
typeof biasLat === 'number' && typeof biasLng === 'number'
? new google.maps.LatLng(biasLat, biasLng)
: undefined;
const placeReq: google.maps.places.AutocompletionRequest = {
input: debouncedQuery,
language: googleLangCode(locale),
...(locationForBias
? { locationBias: { center: locationForBias, radius: 5000 }, origin: locationForBias }
: {}),
};
service.getPlacePredictions(placeReq, (results, status) => {
if (cancelled) return;
if (status !== google.maps.places.PlacesServiceStatus.OK || !results) {
console.log(`[Map][离线未命中] 路线选点提示: "${trimmedQuery}" 在线提示失败或无结果 (${status})`);
setPredictions([]);
setSuggestionsLoading(false);
return;
}
console.log(`[Map][在线] 路线选点提示: "${trimmedQuery}" -> ${results.length} 个地点`);
setPredictions(
results.map((item) => ({
placeId: item.place_id,
mainText: item.structured_formatting?.main_text ?? item.description ?? '',
secondaryText: item.structured_formatting?.secondary_text ?? '',
distanceMeters: item.distance_meters ?? undefined,
})),
);
setSuggestionsLoading(false);
});
};
void run();
return () => {
cancelled = true;
};
}, [debouncedQuery, google, currentLocation.latitude, currentLocation.longitude, viewportCenter?.lat, viewportCenter?.lng, locale]);
const placeHistory = useMemo(
() =>
searchHistory.filter(
(item): item is MapSearchHistoryEntry & { kind: 'place'; placeId: string } =>
item.kind === 'place' && typeof item.placeId === 'string' && item.placeId.length > 0,
),
[searchHistory],
);
const placeholder = mode === 'origin' ? s.route_setup_select_start : s.route_setup_select_dest;
const handleSelect = (placeId: string, title: string, subtitle?: string) => {
KeyboardService.hide();
inputRef.current?.blur();
onSelectPlace(placeId, title, subtitle);
};
const showHistory = query.trim().length === 0;
return (
<div className="absolute inset-0 z-40 bg-app-surface pointer-events-auto">
<div className="flex h-full flex-col pt-10">
<div className="px-3 pb-2">
<div className="flex items-center gap-3">
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-gray-700 active:bg-gray-100"
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: 'routePicker.close' },
{ onTrigger: onClose },
)}
>
<IcNavBack size={24} />
</button>
<div className="flex h-12 flex-1 items-center gap-2 rounded-full bg-gray-100 px-4">
<input
ref={inputRef}
type="text"
autoFocus
value={query}
placeholder={placeholder}
className="flex-1 bg-transparent text-lg text-gray-900 outline-none placeholder:text-gray-500"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
e.preventDefault();
if (predictions[0]) {
handleSelect(
predictions[0].placeId,
predictions[0].mainText,
predictions[0].secondaryText || undefined,
);
}
}
}}
/>
{query ? (
<button
type="button"
className="text-app-text-muted"
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: 'routePicker.clearQuery' },
{
onTrigger: () => {
flushSync(() => {
setQuery('');
setDebouncedQuery('');
});
inputRef.current?.focus();
},
},
)}
>
<IcClose size={22} />
</button>
) : (
<IcMic size={22} className="text-app-text-muted" />
)}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pb-24">
{showHistory ? (
<>
<button
type="button"
className="flex w-full items-center gap-4 px-4 py-4 text-left active:bg-gray-50"
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: 'routePicker.chooseOnMap' },
{
onTrigger: () => {
KeyboardService.hide();
inputRef.current?.blur();
onChooseOnMap(mode);
},
},
)}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gray-100 text-app-text-muted">
<IcLocation size={20} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-lg text-gray-900">{s.select_on_map}</div>
</div>
</button>
{placeHistory.map((item) => (
<button
key={item.id}
type="button"
className="flex w-full items-start gap-4 px-4 py-4 text-left active:bg-gray-50"
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: 'routePicker.history.select' },
{
onTrigger: () => handleSelect(item.placeId, item.text, item.subtitle),
},
)}
>
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gray-100 text-app-text-muted">
<IcClock size={18} />
</div>
<div className="min-w-0 flex-1 border-b border-gray-100 pb-4">
<div className="truncate text-[17px] font-medium text-gray-900">{item.text}</div>
{item.subtitle ? (
<div className="truncate text-sm text-app-text-muted">{item.subtitle}</div>
) : null}
</div>
</button>
))}
</>
) : (
<div className="py-1">
{suggestionsLoading ? (
<div className="p-8 text-center text-app-text-muted">{s.search_searching}</div>
) : predictions.length > 0 ? (
predictions.map((item) => (
<button
key={item.placeId}
type="button"
className="flex w-full items-start gap-4 px-4 py-4 text-left active:bg-gray-50"
{...bindTap<HTMLButtonElement>(
{ kind: 'action', id: 'routePicker.prediction.select' },
{
onTrigger: () => handleSelect(
item.placeId,
item.mainText,
item.secondaryText || undefined,
),
},
)}
>
<div className="mt-1 flex min-w-[52px] shrink-0 flex-col items-center text-app-text-muted">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100">
<IcLocation size={17} />
</div>
<span className="mt-1 text-xs text-gray-400">
{formatDistanceLabelMeters(item.distanceMeters, locale)}
</span>
</div>
<div className="min-w-0 flex-1 border-b border-gray-100 pb-4">
<div className="truncate text-[17px] font-medium text-gray-900">{item.mainText}</div>
<div className="truncate text-sm text-app-text-muted">{item.secondaryText}</div>
</div>
<div className="pt-1 text-gray-500">
<IcNavBack size={18} className="rotate-180 -rotate-45" />
</div>
</button>
))
) : (
<div className="p-8 text-center text-app-text-muted">{s.search_no_results}</div>
)}
</div>
)}
</div>
{showHistory && counterpart ? (
<div className="absolute inset-x-0 bottom-0 border-t border-gray-100 bg-app-surface px-4 py-4 shadow-up">
<div className="text-[17px] font-medium text-gray-900">{s.route_continue_to}{counterpart.name}</div>
{continuationDuration ? (
<div className="mt-1 text-sm text-app-text-muted">{continuationDuration}</div>
) : null}
</div>
) : null}
</div>
</div>
);
};