From 52546a37e1bc54ffc8b8fe80e1c81ba8a4fb192b Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 29 Jul 2026 16:08:52 +0800 Subject: [PATCH 1/2] feat(frontend): add the map picker Fourth of eight. Location picking is self-contained enough to read on its own: a Baidu Maps integration with two implementations behind one shared types/services/styles trio. The web build loads the JS SDK directly; the native build drives the same SDK inside a WebView and talks to it over a message bridge. MapPicker.tsx is a single-line re-export of the web build. It resolves on neither platform, since Metro takes .native.tsx on device and .web.tsx on web, so its only real effect is to point tsc at the web implementation and leave the native one unchecked. Recorded as-is; the refactor fixes it. --- frontend/src/components/BaiduMapWebView.ts | 181 ++++++++++++ frontend/src/components/MapPicker.native.tsx | 180 ++++++++++++ frontend/src/components/MapPicker.services.ts | 26 ++ frontend/src/components/MapPicker.styles.ts | 150 ++++++++++ frontend/src/components/MapPicker.tsx | 1 + frontend/src/components/MapPicker.types.ts | 12 + frontend/src/components/MapPicker.web.tsx | 262 ++++++++++++++++++ frontend/src/components/MapPickerOverlay.tsx | 216 +++++++++++++++ 8 files changed, 1028 insertions(+) create mode 100644 frontend/src/components/BaiduMapWebView.ts create mode 100644 frontend/src/components/MapPicker.native.tsx create mode 100644 frontend/src/components/MapPicker.services.ts create mode 100644 frontend/src/components/MapPicker.styles.ts create mode 100644 frontend/src/components/MapPicker.tsx create mode 100644 frontend/src/components/MapPicker.types.ts create mode 100644 frontend/src/components/MapPicker.web.tsx create mode 100644 frontend/src/components/MapPickerOverlay.tsx diff --git a/frontend/src/components/BaiduMapWebView.ts b/frontend/src/components/BaiduMapWebView.ts new file mode 100644 index 0000000..2ac1414 --- /dev/null +++ b/frontend/src/components/BaiduMapWebView.ts @@ -0,0 +1,181 @@ +import type { MapLocation } from './MapPicker.types'; + +export type BaiduMapBridgeMessage = + | { type: 'map-ready' } + | { message: string; type: 'map-error' } + | { latitude: number; longitude: number; type: 'selecting' } + | { location: MapLocation; type: 'selected' } + | { message: string; type: 'location-error' } + | { results: MapLocation[]; type: 'search-results' } + | { type: 'search-error' }; + +export function buildBaiduMapDocument(ak: string, initialLocation: MapLocation | null) { + const center = initialLocation ?? { + address: '上海市 · 默认地图中心', + latitude: 31.236305, + longitude: 121.480237, + }; + const initialJson = JSON.stringify(initialLocation); + const centerJson = JSON.stringify(center); + + return ` + + + + + + + + +
+ + +`; +} diff --git a/frontend/src/components/MapPicker.native.tsx b/frontend/src/components/MapPicker.native.tsx new file mode 100644 index 0000000..ed40812 --- /dev/null +++ b/frontend/src/components/MapPicker.native.tsx @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; + +import { BaiduMapBridgeMessage, buildBaiduMapDocument } from './BaiduMapWebView'; +import { BAIDU_MAP_AK, createCoordinateLocation } from './MapPicker.services'; +import { mapPickerStyles as styles } from './MapPicker.styles'; +import { MapPickerOverlay } from './MapPickerOverlay'; +import type { MapLocation, MapPickerProps } from './MapPicker.types'; + +const SEARCH_TIMEOUT_MS = 8000; + +type PendingSearch = { + reject: (error: Error) => void; + resolve: (locations: MapLocation[]) => void; + timeout: ReturnType; +}; + +export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) { + const webViewRef = useRef(null); + const pendingSearchRef = useRef(null); + const [selection, setSelection] = useState(initialLocation); + const [locating, setLocating] = useState(false); + const [locationError, setLocationError] = useState(null); + const [mapReady, setMapReady] = useState(false); + const [mapError, setMapError] = useState( + BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。', + ); + const document = useMemo( + () => buildBaiduMapDocument(BAIDU_MAP_AK, initialLocation), + [initialLocation], + ); + + useEffect(() => { + return () => { + if (pendingSearchRef.current) { + clearTimeout(pendingSearchRef.current.timeout); + pendingSearchRef.current = null; + } + }; + }, []); + + const failPendingSearch = useCallback((message: string) => { + const pending = pendingSearchRef.current; + if (!pending) return; + clearTimeout(pending.timeout); + pending.reject(new Error(message)); + pendingSearchRef.current = null; + }, []); + + const handleMessage = useCallback( + ({ nativeEvent }: WebViewMessageEvent) => { + let message: BaiduMapBridgeMessage; + try { + message = JSON.parse(nativeEvent.data) as BaiduMapBridgeMessage; + } catch { + return; + } + + if (message.type === 'map-ready') { + setMapReady(true); + setMapError(null); + if (!initialLocation) { + webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;'); + } + return; + } + if (message.type === 'map-error') { + setMapReady(false); + setMapError(message.message); + failPendingSearch(message.message); + return; + } + if (message.type === 'selecting') { + setLocationError(null); + setSelection(createCoordinateLocation(message.latitude, message.longitude)); + setLocating(true); + return; + } + if (message.type === 'selected') { + setSelection(message.location); + setLocating(false); + setLocationError(null); + return; + } + if (message.type === 'location-error') { + setLocating(false); + setLocationError(message.message); + return; + } + if (message.type === 'search-results') { + const pending = pendingSearchRef.current; + if (!pending) return; + clearTimeout(pending.timeout); + pending.resolve(message.results); + pendingSearchRef.current = null; + return; + } + if (message.type === 'search-error') { + failPendingSearch('Baidu place search failed'); + } + }, + [failPendingSearch, initialLocation], + ); + + const searchLocations = useCallback( + (query: string) => { + return new Promise((resolve, reject) => { + if (!mapReady || !webViewRef.current) { + reject(new Error('Baidu map is not ready')); + return; + } + + failPendingSearch('A newer search replaced this request'); + const timeout = setTimeout(() => { + failPendingSearch('Baidu place search timed out'); + }, SEARCH_TIMEOUT_MS); + pendingSearchRef.current = { reject, resolve, timeout }; + webViewRef.current.injectJavaScript( + `window.__timeflowSearch(${JSON.stringify(query)}); true;`, + ); + }); + }, + [failPendingSearch, mapReady], + ); + + const selectSearchResult = (location: MapLocation) => { + setLocationError(null); + setLocating(false); + setSelection(location); + webViewRef.current?.injectJavaScript( + `window.__timeflowSelect(${location.longitude}, ${location.latitude}); true;`, + ); + }; + + const locateCurrentPosition = () => { + setLocationError(null); + setLocating(true); + webViewRef.current?.injectJavaScript('window.__timeflowLocate(); true;'); + }; + + return ( + + {BAIDU_MAP_AK ? ( + { + setMapReady(false); + setMapError('地图加载失败,请检查网络或百度地图密钥配置。'); + }} + onMessage={handleMessage} + originWhitelist={['https://*']} + ref={webViewRef} + scrollEnabled={false} + setSupportMultipleWindows={false} + source={{ baseUrl: 'https://timeflow.local/', html: document }} + style={styles.mapCanvas} + /> + ) : ( + + )} + selection && onConfirm(selection)} + onSearch={searchLocations} + onSelectSearchResult={selectSearchResult} + selection={selection} + /> + + ); +} diff --git a/frontend/src/components/MapPicker.services.ts b/frontend/src/components/MapPicker.services.ts new file mode 100644 index 0000000..633f016 --- /dev/null +++ b/frontend/src/components/MapPicker.services.ts @@ -0,0 +1,26 @@ +import type { MapLocation } from './MapPicker.types'; + +export const BAIDU_MAP_AK = process.env.EXPO_PUBLIC_BAIDU_MAP_AK?.trim() ?? ''; +export const BAIDU_COORDINATE_SYSTEM = 'bd09ll' as const; + +export const SHANGHAI_CENTER: MapLocation = { + address: '上海市 · 默认地图中心', + latitude: 31.236305, + longitude: 121.480237, +}; + +export function coordinateAddress(latitude: number, longitude: number) { + return `百度地图选点 · ${latitude.toFixed(5)}, ${longitude.toFixed(5)}`; +} + +export function createCoordinateLocation(latitude: number, longitude: number): MapLocation { + return { + address: coordinateAddress(latitude, longitude), + latitude, + longitude, + }; +} + +export function readablePoiAddress(title: string, address?: string) { + return address?.trim() ? `${title} · ${address.trim()}` : title; +} diff --git a/frontend/src/components/MapPicker.styles.ts b/frontend/src/components/MapPicker.styles.ts new file mode 100644 index 0000000..9949cf7 --- /dev/null +++ b/frontend/src/components/MapPicker.styles.ts @@ -0,0 +1,150 @@ +import { StyleSheet } from 'react-native'; + +import { colors } from '../constants/theme'; + +export const mapPickerStyles = StyleSheet.create({ + screen: { backgroundColor: '#D8E4DE', flex: 1 }, + mapCanvas: { flex: 1 }, + mapError: { + alignItems: 'center', + backgroundColor: 'rgba(247, 248, 245, 0.96)', + borderColor: colors.line, + borderRadius: 16, + borderWidth: 1, + left: 44, + paddingHorizontal: 20, + paddingVertical: 18, + position: 'absolute', + right: 44, + top: '38%', + zIndex: 900, + }, + mapErrorTitle: { color: colors.ink, fontSize: 14, fontWeight: '800', marginTop: 10 }, + mapErrorText: { + color: colors.sub, + fontSize: 11, + lineHeight: 17, + marginTop: 5, + textAlign: 'center', + }, + topArea: { + left: 14, + position: 'absolute', + right: 14, + top: 14, + zIndex: 1000, + }, + toolbar: { alignItems: 'center', flexDirection: 'row', gap: 8 }, + searchBox: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: 14, + borderWidth: 1, + elevation: 4, + flex: 1, + flexDirection: 'row', + height: 48, + paddingHorizontal: 12, + }, + searchInput: { + borderWidth: 0, + color: colors.ink, + flex: 1, + fontSize: 13, + height: 46, + marginHorizontal: 8, + outlineColor: 'transparent', + outlineStyle: 'solid', + outlineWidth: 0, + paddingVertical: 0, + }, + searchButton: { + alignItems: 'center', + height: 34, + justifyContent: 'center', + width: 30, + }, + locateButton: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: 14, + borderWidth: 1, + elevation: 4, + height: 48, + justifyContent: 'center', + width: 42, + }, + locateButtonActive: { backgroundColor: colors.limeSoft }, + searchResults: { + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: 14, + borderWidth: 1, + marginLeft: 50, + marginTop: 7, + overflow: 'hidden', + }, + searchResult: { + alignItems: 'center', + borderBottomColor: colors.line, + borderBottomWidth: 1, + flexDirection: 'row', + minHeight: 48, + paddingHorizontal: 12, + paddingVertical: 9, + }, + searchResultLast: { borderBottomWidth: 0 }, + searchResultText: { color: colors.ink, flex: 1, fontSize: 12, lineHeight: 17, marginLeft: 9 }, + searchMessage: { color: colors.sub, fontSize: 11, padding: 14, textAlign: 'center' }, + locationError: { + alignSelf: 'center', + backgroundColor: 'rgba(247, 248, 245, 0.96)', + borderColor: colors.line, + borderRadius: 11, + borderWidth: 1, + marginTop: 7, + paddingHorizontal: 10, + paddingVertical: 7, + }, + locationErrorText: { color: colors.sub, fontSize: 11 }, + selectionCard: { + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: 18, + borderWidth: 1, + bottom: 16, + elevation: 6, + left: 14, + padding: 14, + position: 'absolute', + right: 14, + zIndex: 1000, + }, + selectionHeading: { alignItems: 'center', flexDirection: 'row' }, + selectionIcon: { + alignItems: 'center', + backgroundColor: colors.limeSoft, + borderRadius: 12, + height: 38, + justifyContent: 'center', + marginRight: 10, + width: 38, + }, + selectionCopy: { flex: 1 }, + selectionKicker: { color: '#728456', fontSize: 10, fontWeight: '800' }, + selectionAddress: { color: colors.ink, fontSize: 13, lineHeight: 19, marginTop: 4 }, + selectionHint: { color: colors.sub, fontSize: 11, lineHeight: 17, marginTop: 4 }, + selectionMeta: { color: colors.muted, fontSize: 10, marginTop: 8 }, + confirmButton: { + alignItems: 'center', + backgroundColor: colors.deep, + borderRadius: 12, + height: 48, + justifyContent: 'center', + marginTop: 12, + }, + confirmButtonDisabled: { opacity: 0.38 }, + confirmButtonText: { color: colors.surface, fontSize: 13, fontWeight: '800' }, +}); diff --git a/frontend/src/components/MapPicker.tsx b/frontend/src/components/MapPicker.tsx new file mode 100644 index 0000000..798d2be --- /dev/null +++ b/frontend/src/components/MapPicker.tsx @@ -0,0 +1 @@ +export { MapPicker } from './MapPicker.web'; diff --git a/frontend/src/components/MapPicker.types.ts b/frontend/src/components/MapPicker.types.ts new file mode 100644 index 0000000..0f31c19 --- /dev/null +++ b/frontend/src/components/MapPicker.types.ts @@ -0,0 +1,12 @@ +export type MapLocation = { + address: string; + latitude: number; + longitude: number; + name?: string; +}; + +export type MapPickerProps = { + initialLocation: MapLocation | null; + onCancel: () => void; + onConfirm: (location: MapLocation) => void; +}; diff --git a/frontend/src/components/MapPicker.web.tsx b/frontend/src/components/MapPicker.web.tsx new file mode 100644 index 0000000..30e7489 --- /dev/null +++ b/frontend/src/components/MapPicker.web.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { load as loadBaiduMap } from '@baidumap/jsapi-loader'; +import { View } from 'react-native'; + +import { + BAIDU_MAP_AK, + createCoordinateLocation, + readablePoiAddress, + SHANGHAI_CENTER, +} from './MapPicker.services'; +import { mapPickerStyles as styles } from './MapPicker.styles'; +import { MapPickerOverlay } from './MapPickerOverlay'; +import type { MapLocation, MapPickerProps } from './MapPicker.types'; + +const MAP_REQUEST_TIMEOUT_MS = 6000; +const MAP_LOAD_TIMEOUT_MS = 5000; +const MARKER_SVG = encodeURIComponent( + '', +); + +export function MapPicker({ initialLocation, onCancel, onConfirm }: MapPickerProps) { + const mapHostRef = useRef(null); + const bmapRef = useRef(null); + const mapRef = useRef(null); + const markerRef = useRef(null); + const requestRef = useRef(0); + const [selection, setSelection] = useState(initialLocation); + const [locating, setLocating] = useState(false); + const [locationError, setLocationError] = useState(null); + const [mapReady, setMapReady] = useState(false); + const [mapError, setMapError] = useState( + BAIDU_MAP_AK ? null : '缺少百度地图浏览器端密钥,请完成地图服务配置。', + ); + + const moveMarker = useCallback((location: MapLocation) => { + const BMapApi = bmapRef.current; + const map = mapRef.current; + if (!BMapApi || !map) return; + + const point = new BMapApi.Point(location.longitude, location.latitude); + if (markerRef.current) { + markerRef.current.setPosition(point); + return; + } + + const icon = new BMapApi.Icon( + `data:image/svg+xml;charset=utf-8,${MARKER_SVG}`, + new BMapApi.Size(28, 28), + { anchor: new BMapApi.Size(14, 14) }, + ); + markerRef.current = new BMapApi.Marker(point, { icon }); + map.addOverlay(markerRef.current); + }, []); + + const selectCoordinates = useCallback( + (latitude: number, longitude: number) => { + const BMapApi = bmapRef.current; + if (!BMapApi) return; + + const requestId = requestRef.current + 1; + requestRef.current = requestId; + const pendingLocation = createCoordinateLocation(latitude, longitude); + let completed = false; + + setLocationError(null); + setSelection(pendingLocation); + moveMarker(pendingLocation); + setLocating(true); + + const finish = (address?: string) => { + if (completed || requestRef.current !== requestId) return; + completed = true; + window.clearTimeout(timeout); + if (address?.trim()) setSelection({ ...pendingLocation, address: address.trim() }); + setLocating(false); + }; + + const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS); + const geocoder = new BMapApi.Geocoder({ language: 'zh-CN' }); + geocoder.getLocation( + new BMapApi.Point(longitude, latitude), + (result: BMap.GeocoderResult | null) => finish(result?.address), + ); + }, + [moveMarker], + ); + + const locateCurrentPosition = useCallback(() => { + const BMapApi = bmapRef.current; + const map = mapRef.current; + if (!BMapApi || !map) return; + + setLocationError(null); + setLocating(true); + const geolocation = new BMapApi.Geolocation(); + geolocation.getCurrentPosition( + (result) => { + if (geolocation.getStatus() !== 0 || !result?.point) { + setLocating(false); + setLocationError('无法获取当前位置,请允许定位权限后重试。'); + return; + } + + map.setCenter(result.point, { noAnimation: false }); + map.setZoom(17, { noAnimation: false }); + selectCoordinates(result.point.lat, result.point.lng); + }, + { enableHighAccuracy: true }, + ); + }, [selectCoordinates]); + + useEffect(() => { + const host = mapHostRef.current as unknown as HTMLElement | null; + if (!host) return; + + if (!BAIDU_MAP_AK) return; + + let disposed = false; + const loadTimeout = window.setTimeout(() => { + if (!disposed) { + setMapError('请在百度地图控制台为此 Key 开通 JavaScript API 服务后重试。'); + } + }, MAP_LOAD_TIMEOUT_MS); + + void loadBaiduMap({ + ak: BAIDU_MAP_AK, + globalConfig: { coordType: 'bd09ll' }, + timeout: 10000, + version: '4.0', + }) + .then((namespace: typeof BMap) => { + if (disposed) return; + window.clearTimeout(loadTimeout); + + const center = initialLocation ?? SHANGHAI_CENTER; + const point = new namespace.Point(center.longitude, center.latitude); + const map = new namespace.Map(host, { + center: point, + enablePinchZoom: true, + enableWheelZoom: true, + fixCenterWhenResize: true, + zoom: initialLocation ? 17 : 14, + }); + + bmapRef.current = namespace; + mapRef.current = map; + if (initialLocation) moveMarker(initialLocation); + map.addEventListener('click', (event) => { + selectCoordinates(event.point.lat, event.point.lng); + }); + setMapError(null); + setMapReady(true); + if (!initialLocation) locateCurrentPosition(); + }) + .catch(() => { + if (!disposed) { + window.clearTimeout(loadTimeout); + setMapError('地图加载失败,请检查网络或百度地图密钥配置。'); + } + }); + + return () => { + disposed = true; + window.clearTimeout(loadTimeout); + requestRef.current += 1; + markerRef.current = null; + const map = mapRef.current; + if (map) { + try { + const destroy = (map as unknown as { destroy?: () => void }).destroy; + if (typeof destroy === 'function') { + destroy.call(map); + } else { + (map as unknown as { clearOverlays?: () => void }).clearOverlays?.(); + } + } catch { + // Baidu may have already torn down the map while the overlay closes. + } + } + mapRef.current = null; + bmapRef.current = null; + }; + }, [initialLocation, locateCurrentPosition, moveMarker, selectCoordinates]); + + const searchLocations = useCallback((query: string) => { + return new Promise((resolve, reject) => { + const BMapApi = bmapRef.current; + const map = mapRef.current; + if (!BMapApi || !map) { + reject(new Error('Baidu map is not ready')); + return; + } + + let completed = false; + const finish = (locations?: MapLocation[]) => { + if (completed) return; + completed = true; + window.clearTimeout(timeout); + if (locations) resolve(locations); + else reject(new Error('Baidu place search failed')); + }; + const timeout = window.setTimeout(() => finish(), MAP_REQUEST_TIMEOUT_MS); + const localSearch = new BMapApi.LocalSearch(map, { + onSearchComplete: (rawResults) => { + const result = Array.isArray(rawResults) ? rawResults[0] : rawResults; + if (!result) { + finish([]); + return; + } + + const locations: MapLocation[] = []; + const count = Math.min(result.getCurrentNumPois(), 5); + for (let index = 0; index < count; index += 1) { + const poi = result.getPoi(index); + if (!poi?.point) continue; + locations.push({ + address: readablePoiAddress(poi.title, poi.address), + latitude: poi.point.lat, + longitude: poi.point.lng, + }); + } + finish(locations); + }, + pageCapacity: 5, + renderOptions: { autoViewport: false }, + }); + localSearch.search(query); + }); + }, []); + + const selectSearchResult = (location: MapLocation) => { + const BMapApi = bmapRef.current; + const map = mapRef.current; + if (!BMapApi || !map) return; + + setLocationError(null); + requestRef.current += 1; + setLocating(false); + setSelection(location); + moveMarker(location); + map.setCenter(new BMapApi.Point(location.longitude, location.latitude), { noAnimation: false }); + map.setZoom(17, { noAnimation: false }); + }; + + return ( + + + selection && onConfirm(selection)} + onSearch={searchLocations} + onSelectSearchResult={selectSearchResult} + selection={selection} + /> + + ); +} diff --git a/frontend/src/components/MapPickerOverlay.tsx b/frontend/src/components/MapPickerOverlay.tsx new file mode 100644 index 0000000..555b72e --- /dev/null +++ b/frontend/src/components/MapPickerOverlay.tsx @@ -0,0 +1,216 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { LocateFixed, MapPin, Search } from 'lucide-react-native'; +import { Pressable, Text, TextInput, View } from 'react-native'; + +import { colors } from '../constants/theme'; +import { BackButton } from './BackButton'; +import { mapPickerStyles as styles } from './MapPicker.styles'; +import type { MapLocation } from './MapPicker.types'; + +type MapPickerOverlayProps = { + mapError: string | null; + mapReady: boolean; + locating: boolean; + locationError: string | null; + onCancel: () => void; + onLocate: () => void; + onConfirm: () => void; + onSearch: (query: string) => Promise; + onSelectSearchResult: (location: MapLocation) => void; + selection: MapLocation | null; +}; + +export function MapPickerOverlay({ + mapError, + mapReady, + locating, + locationError, + onCancel, + onLocate, + onConfirm, + onSearch, + onSelectSearchResult, + selection, +}: MapPickerOverlayProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [searched, setSearched] = useState(false); + const [searchFailed, setSearchFailed] = useState(false); + const searchRequestRef = useRef(0); + + const search = useCallback( + async (value: string) => { + const nextQuery = value.trim(); + if (!nextQuery || !mapReady) return; + + const requestId = searchRequestRef.current + 1; + searchRequestRef.current = requestId; + setSearching(true); + setSearched(false); + setSearchFailed(false); + try { + const nextResults = await onSearch(nextQuery); + if (requestId !== searchRequestRef.current) return; + setResults(nextResults); + setSearched(true); + } catch { + if (requestId !== searchRequestRef.current) return; + setResults([]); + setSearchFailed(true); + } finally { + if (requestId === searchRequestRef.current) setSearching(false); + } + }, + [mapReady, onSearch], + ); + + useEffect(() => { + const nextQuery = query.trim(); + if (!nextQuery || !mapReady) return; + + const timer = setTimeout(() => { + void search(nextQuery); + }, 320); + return () => clearTimeout(timer); + }, [mapReady, query, search]); + + const chooseResult = (location: MapLocation) => { + searchRequestRef.current += 1; + setSearching(false); + setQuery(''); + setResults([]); + setSearched(false); + onSelectSearchResult(location); + }; + + return ( + <> + + + + + + { + setQuery(value); + searchRequestRef.current += 1; + setSearching(false); + setSearchFailed(false); + setSearched(false); + if (!value.trim()) { + setResults([]); + } + }} + onSubmitEditing={() => void search(query)} + placeholder="搜索地点或地址" + placeholderTextColor="#909892" + returnKeyType="search" + style={styles.searchInput} + value={query} + /> + void search(query)} + style={styles.searchButton} + > + + + + + + + + + {(searching || searchFailed || searched) && ( + + {searching ? ( + 正在搜索... + ) : searchFailed ? ( + 搜索暂时不可用,请直接在地图上选点 + ) : results.length === 0 ? ( + 没有找到相关地点 + ) : ( + results.map((result, index) => ( + chooseResult(result)} + style={[ + styles.searchResult, + index === results.length - 1 && styles.searchResultLast, + ]} + > + + + {result.address} + + + )) + )} + + )} + {locationError ? ( + + {locationError} + + ) : null} + + + {mapError && ( + + + 百度地图暂时不可用 + {mapError} + + )} + + + + + + + + 选中的地点 + {selection ? ( + + {locating ? '正在获取详细地址...' : selection.address} + + ) : locating ? ( + 正在获取当前位置... + ) : ( + 点击地图,或搜索后选择一个地点 + )} + + + {selection && ( + + {selection.latitude.toFixed(5)}, {selection.longitude.toFixed(5)} · 百度地图 · BD-09 + + )} + + 确认这个地点 + + + + ); +} From f97a12c83fe3ab31a4517f44cb5ccf2aaa9b4a6e Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 31 Jul 2026 19:18:18 +0800 Subject: [PATCH 2/2] fix(frontend): address map picker review feedback --- README.md | 7 ++ frontend/.env.example | 2 + frontend/src/components/BaiduMapWebView.ts | 13 ++- frontend/src/screens/HomeScreen.tsx | 101 ++++++++++++++++++++- 4 files changed, 118 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ef59618..c6c9ff9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ # TimeFlow +## Frontend map setup +Copy `frontend/.env.example` to `frontend/.env` and set +`EXPO_PUBLIC_BAIDU_MAP_AK` to a Baidu Maps browser-side AK. Enable JavaScript +API v4 for that AK in the Baidu Maps console and add the development and +production web origins to its Referer whitelist. Native builds render the +same JavaScript API in a WebView, so its configured `baseUrl` +(`https://timeflow.local/`) must also be allowed by the AK configuration. diff --git a/frontend/.env.example b/frontend/.env.example index 877160b..591fdf0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,2 +1,4 @@ # Android emulator: 10.0.2.2 reaches the host machine. EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1 +# Baidu Maps browser-side AK with JavaScript API v4 enabled. +EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak diff --git a/frontend/src/components/BaiduMapWebView.ts b/frontend/src/components/BaiduMapWebView.ts index 2ac1414..89cf0b4 100644 --- a/frontend/src/components/BaiduMapWebView.ts +++ b/frontend/src/components/BaiduMapWebView.ts @@ -9,14 +9,23 @@ export type BaiduMapBridgeMessage = | { results: MapLocation[]; type: 'search-results' } | { type: 'search-error' }; +function serializeForInlineScript(value: unknown) { + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + export function buildBaiduMapDocument(ak: string, initialLocation: MapLocation | null) { const center = initialLocation ?? { address: '上海市 · 默认地图中心', latitude: 31.236305, longitude: 121.480237, }; - const initialJson = JSON.stringify(initialLocation); - const centerJson = JSON.stringify(center); + const initialJson = serializeForInlineScript(initialLocation); + const centerJson = serializeForInlineScript(center); return ` diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index b86246e..4fcf946 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -1,12 +1,54 @@ +import { useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +import { MapPin } from 'lucide-react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; -import { colors, spacing } from '../constants/theme'; +import { MapPicker } from '../components/MapPicker'; +import type { MapLocation } from '../components/MapPicker.types'; +import { colors, radii, spacing } from '../constants/theme'; export function HomeScreen() { + const [location, setLocation] = useState(null); + const [pickingLocation, setPickingLocation] = useState(false); + + if (pickingLocation) { + return ( + setPickingLocation(false)} + onConfirm={(nextLocation) => { + setLocation(nextLocation); + setPickingLocation(false); + }} + /> + ); + } + return ( Timeflow + 为日程选择提醒地点 + {location ? ( + + + + + {location.address} + + + {location.latitude.toFixed(5)}, {location.longitude.toFixed(5)} + + + + ) : null} + setPickingLocation(true)} + style={({ pressed }) => [styles.mapButton, pressed && styles.mapButtonPressed]} + > + + {location ? '重新选择地点' : '打开地图选点'} + ); @@ -18,10 +60,63 @@ const styles = StyleSheet.create({ backgroundColor: colors.background, flex: 1, justifyContent: 'center', + padding: spacing.xl, }, title: { color: colors.text, - fontSize: 24, + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: colors.sub, + fontSize: 15, + marginBottom: spacing.lg, + marginTop: spacing.xs, + }, + locationSummary: { + alignItems: 'center', + backgroundColor: colors.surface, + borderColor: colors.line, + borderRadius: radii.sm, + borderWidth: 1, + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.md, + maxWidth: 420, padding: spacing.md, + width: '100%', + }, + locationCopy: { + flex: 1, + }, + locationAddress: { + color: colors.ink, + fontSize: 15, + fontWeight: '600', + }, + locationCoordinates: { + color: colors.sub, + fontSize: 12, + marginTop: spacing.xs, + }, + mapButton: { + alignItems: 'center', + backgroundColor: colors.lime, + borderRadius: radii.sm, + flexDirection: 'row', + gap: spacing.sm, + justifyContent: 'center', + maxWidth: 420, + minHeight: 48, + paddingHorizontal: spacing.lg, + width: '100%', + }, + mapButtonPressed: { + opacity: 0.78, + }, + mapButtonText: { + color: colors.deep, + fontSize: 15, + fontWeight: '700', }, });