- 新增 Client/src/lib/amap.ts:高德 JS 定位封装 - weather.ts / useWeather.ts:带经纬度/城市请求,定位失败不兜底 - Server weather.rs:相应调整 - 文档同步;.gitignore 忽略本地 .codegraph 索引 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
4.2 KiB
TypeScript
123 lines
4.2 KiB
TypeScript
// 高德 JS API(AMap.Geolocation)前端定位封装。
|
||
//
|
||
// 设计:
|
||
// - Key 与安全密钥都从环境变量读,**不写死**:`VITE_AMAP_JS_KEY` / `VITE_AMAP_JS_CODE`。
|
||
// 两者是高德「Web端(JS API)」专属密钥,和后端的 Web 服务 REST `AMAP_KEY` 是两套。
|
||
// - 未配置 Key → `locate()` 直接返回 null,由后端 IP 定位兜底(即旧行为,绝不退化)。
|
||
// - 定位两段式:先浏览器原生定位(GPS/WiFi,精度高,但**需 HTTPS**,非 localhost 的
|
||
// HTTP 页面下浏览器会禁用);失败/被拒 → 退回高德纯 IP 城市定位(getCityInfo)。
|
||
// - 任何失败都吞掉返回 null(不打扰页面),后端继续兜底。
|
||
|
||
const KEY = import.meta.env.VITE_AMAP_JS_KEY as string | undefined
|
||
const CODE = import.meta.env.VITE_AMAP_JS_CODE as string | undefined
|
||
|
||
export interface GeoResult {
|
||
/** 经度 */
|
||
lon: number
|
||
/** 纬度 */
|
||
lat: number
|
||
/** 城市名(直辖市/定位精度不足时可能为空) */
|
||
city?: string
|
||
}
|
||
|
||
// 高德 SDK 没有官方类型,这里只声明用到的最小子集。
|
||
interface AMapGeolocation {
|
||
getCurrentPosition(cb: (status: string, result: GeoPositionResult) => void): void
|
||
getCityInfo(cb: (status: string, result: GeoCityResult) => void): void
|
||
}
|
||
interface GeoPositionResult {
|
||
position?: { lng: number; lat: number }
|
||
addressComponent?: { city?: string; province?: string }
|
||
}
|
||
interface GeoCityResult {
|
||
/** [lng, lat] 城市中心 */
|
||
center?: [number, number]
|
||
city?: string
|
||
province?: string
|
||
}
|
||
interface AMapNamespace {
|
||
plugin(name: string, onload: () => void): void
|
||
Geolocation: new (opts: Record<string, unknown>) => AMapGeolocation
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
AMap?: AMapNamespace
|
||
_AMapSecurityConfig?: { securityJsCode: string }
|
||
}
|
||
}
|
||
|
||
let sdkPromise: Promise<AMapNamespace> | null = null
|
||
|
||
/** 按需注入高德 JSAPI 脚本(单例)。安全密钥须在脚本加载前挂到 window。 */
|
||
function loadSdk(): Promise<AMapNamespace> {
|
||
if (sdkPromise) return sdkPromise
|
||
sdkPromise = new Promise((resolve, reject) => {
|
||
if (!KEY) return reject(new Error('AMap JS key not configured'))
|
||
if (window.AMap) return resolve(window.AMap)
|
||
if (CODE) window._AMapSecurityConfig = { securityJsCode: CODE }
|
||
|
||
const s = document.createElement('script')
|
||
s.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(KEY)}&plugin=AMap.Geolocation`
|
||
s.async = true
|
||
s.onload = () =>
|
||
window.AMap ? resolve(window.AMap) : reject(new Error('AMap SDK init failed'))
|
||
s.onerror = () => reject(new Error('AMap SDK script load error'))
|
||
document.head.appendChild(s)
|
||
})
|
||
return sdkPromise
|
||
}
|
||
|
||
/**
|
||
* 取当前位置。未配置 Key 或定位失败时返回 null(交给后端兜底)。
|
||
* 不会抛错。
|
||
*/
|
||
export async function locate(): Promise<GeoResult | null> {
|
||
if (!KEY) return null
|
||
|
||
let AMap: AMapNamespace
|
||
try {
|
||
AMap = await loadSdk()
|
||
} catch {
|
||
return null
|
||
}
|
||
|
||
return new Promise<GeoResult | null>((resolve) => {
|
||
let settled = false
|
||
const finish = (r: GeoResult | null) => {
|
||
if (!settled) {
|
||
settled = true
|
||
resolve(r)
|
||
}
|
||
}
|
||
// 整体兜底超时,避免插件卡死不回调。
|
||
const timer = setTimeout(() => finish(null), 12_000)
|
||
const done = (r: GeoResult | null) => {
|
||
clearTimeout(timer)
|
||
finish(r)
|
||
}
|
||
|
||
AMap.plugin('AMap.Geolocation', () => {
|
||
const geo = new AMap.Geolocation({ enableHighAccuracy: true, timeout: 10_000 })
|
||
geo.getCurrentPosition((status, result) => {
|
||
if (status === 'complete' && result.position) {
|
||
done({
|
||
lon: result.position.lng,
|
||
lat: result.position.lat,
|
||
city: result.addressComponent?.city || result.addressComponent?.province,
|
||
})
|
||
} else {
|
||
// 原生/精确定位失败(多因 HTTP 下被禁或用户拒绝)→ 退回纯 IP 城市定位。
|
||
geo.getCityInfo((s2, r2) => {
|
||
if (s2 === 'complete' && r2.center) {
|
||
done({ lon: r2.center[0], lat: r2.center[1], city: r2.city || r2.province })
|
||
} else {
|
||
done(null)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
})
|
||
})
|
||
}
|