feat(weather): 起始页天气/定位细节完善
- 新增 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>
This commit is contained in:
parent
b2a0b563c9
commit
4c8c4360ad
3
.gitignore
vendored
3
.gitignore
vendored
@ -28,6 +28,9 @@ lerna-debug.log*
|
|||||||
Server/target
|
Server/target
|
||||||
Server/.vite
|
Server/.vite
|
||||||
|
|
||||||
|
# --- Tooling(本地索引/缓存,勿入库)---
|
||||||
|
.codegraph/
|
||||||
|
|
||||||
# --- Secrets(密钥/私钥/本地环境,永不入库)---
|
# --- Secrets(密钥/私钥/本地环境,永不入库)---
|
||||||
*.pem
|
*.pem
|
||||||
*.env
|
*.env
|
||||||
|
|||||||
@ -27,9 +27,25 @@ export type WeatherResult =
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。 */
|
/** 前端定位结果(来自 AMap.Geolocation);缺省则后端走 IP 定位兜底。 */
|
||||||
export async function fetchWeather(): Promise<WeatherResult> {
|
export interface WeatherLocation {
|
||||||
const res = await fetch(`${API_BASE}/api/web/weather`)
|
lon: number
|
||||||
|
lat: number
|
||||||
|
city?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取一次天气;网络层失败(后端不可达等)会抛错,由调用方兜底。
|
||||||
|
* 传入 `loc` 时把经纬度/城市带给后端,后端据此跳过高德 IP 定位、直接取天气。
|
||||||
|
*/
|
||||||
|
export async function fetchWeather(loc?: WeatherLocation): Promise<WeatherResult> {
|
||||||
|
let url = `${API_BASE}/api/web/weather`
|
||||||
|
if (loc) {
|
||||||
|
const p = new URLSearchParams({ lon: String(loc.lon), lat: String(loc.lat) })
|
||||||
|
if (loc.city) p.set('city', loc.city)
|
||||||
|
url += `?${p.toString()}`
|
||||||
|
}
|
||||||
|
const res = await fetch(url)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
return (await res.json()) as WeatherResult
|
return (await res.json()) as WeatherResult
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { fetchWeather, type WeatherResult } from '../api/weather'
|
import { fetchWeather, type WeatherResult } from '../api/weather'
|
||||||
|
import { locate } from '../lib/amap'
|
||||||
|
|
||||||
export interface WeatherState {
|
export interface WeatherState {
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@ -21,7 +22,9 @@ export function useWeather(intervalMs = 30 * 60 * 1000): WeatherState {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchWeather()
|
// 先前端定位(未配 JS Key 或失败 → null,后端用 IP 兜底)。locate 不抛错。
|
||||||
|
const geo = await locate()
|
||||||
|
const data = await fetchWeather(geo ?? undefined)
|
||||||
if (!cancelled) setState({ loading: false, data, error: false })
|
if (!cancelled) setState({ loading: false, data, error: false })
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setState({ loading: false, data: null, error: true })
|
if (!cancelled) setState({ loading: false, data: null, error: true })
|
||||||
|
|||||||
122
Client/src/lib/amap.ts
Normal file
122
Client/src/lib/amap.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
// 高德 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -99,12 +99,26 @@ struct Inner {
|
|||||||
cache: HashMap<String, CacheEntry>,
|
cache: HashMap<String, CacheEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Located {
|
/// 定位结果:城市名 + 和风用的 "lon,lat" 坐标。
|
||||||
|
/// 既可来自高德 IP 定位,也可来自前端 AMap.Geolocation 插件。
|
||||||
|
pub struct Located {
|
||||||
city: String,
|
city: String,
|
||||||
/// "lon,lat"
|
/// "lon,lat"
|
||||||
coord: String,
|
coord: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Located {
|
||||||
|
/// 前端定位(AMap.Geolocation)传来的经纬度 → Located。
|
||||||
|
/// 坐标按和风要求格式化成 "lon,lat"(两位小数足够,且能把附近用户聚到同一缓存键)。
|
||||||
|
pub fn from_front(lon: f64, lat: f64, city: Option<String>) -> Self {
|
||||||
|
let city = city
|
||||||
|
.map(|c| c.trim().to_string())
|
||||||
|
.filter(|c| !c.is_empty())
|
||||||
|
.unwrap_or_else(|| "未知".to_string());
|
||||||
|
Self { city, coord: format!("{lon:.2},{lat:.2}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 和风 JWT 认证所需材料(控制台创建凭据时拿到)。
|
/// 和风 JWT 认证所需材料(控制台创建凭据时拿到)。
|
||||||
struct QWeatherAuth {
|
struct QWeatherAuth {
|
||||||
/// Ed25519 私钥(PKCS#8 PEM)解析出的签名密钥
|
/// Ed25519 私钥(PKCS#8 PEM)解析出的签名密钥
|
||||||
@ -222,20 +236,34 @@ impl WeatherService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。
|
/// 取天气:先查缓存,再查额度,最后才调上游。注意全程不跨 await 持锁。
|
||||||
pub async fn get(&self, ip: &str) -> WeatherOutcome {
|
///
|
||||||
if self.amap_key.is_none() || self.qweather.is_none() {
|
/// `front` 为前端 AMap.Geolocation 传来的定位(坐标 + 城市):
|
||||||
|
/// - 有:直接用,**不调高德**(也不消耗高德额度),只调和风。
|
||||||
|
/// - 无:退回高德 IP 定位兜底(需 `AMAP_KEY`)。
|
||||||
|
pub async fn get(&self, ip: &str, front: Option<Located>) -> WeatherOutcome {
|
||||||
|
if self.qweather.is_none() {
|
||||||
|
return WeatherOutcome::NotConfigured;
|
||||||
|
}
|
||||||
|
// 没有前端坐标时才依赖高德 IP 定位,故此时才要求 AMAP_KEY。
|
||||||
|
if front.is_none() && self.amap_key.is_none() {
|
||||||
return WeatherOutcome::NotConfigured;
|
return WeatherOutcome::NotConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 命中新鲜缓存直接返回;否则预检两家额度(任一用尽即报)。
|
// 缓存键:前端定位按坐标聚合(不同位置各一份),IP 兜底按访问者 IP。
|
||||||
|
let cache_key = match &front {
|
||||||
|
Some(f) => format!("c:{}", f.coord),
|
||||||
|
None => format!("ip:{ip}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) 命中新鲜缓存直接返回;否则预检所需额度(走 IP 兜底才需要高德)。
|
||||||
{
|
{
|
||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
if let Some(e) = inner.cache.get(ip) {
|
if let Some(e) = inner.cache.get(&cache_key) {
|
||||||
if e.at.elapsed() < self.cache_ttl {
|
if e.at.elapsed() < self.cache_ttl {
|
||||||
return WeatherOutcome::Cached(e.data.clone());
|
return WeatherOutcome::Cached(e.data.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !inner.amap.has_budget() {
|
if front.is_none() && !inner.amap.has_budget() {
|
||||||
return WeatherOutcome::QuotaExceeded { provider: "amap" };
|
return WeatherOutcome::QuotaExceeded { provider: "amap" };
|
||||||
}
|
}
|
||||||
if !inner.qweather.has_budget() {
|
if !inner.qweather.has_budget() {
|
||||||
@ -243,13 +271,16 @@ impl WeatherService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 高德定位(成功才计数)
|
// 2) 定位:前端给了就用;否则高德 IP 定位兜底(成功才计数)。
|
||||||
let loc = match self.locate(ip).await {
|
let loc = match front {
|
||||||
|
Some(f) => f,
|
||||||
|
None => match self.locate(ip).await {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
self.inner.lock().unwrap().amap.consume();
|
self.inner.lock().unwrap().amap.consume();
|
||||||
l
|
l
|
||||||
}
|
}
|
||||||
Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")),
|
Err(e) => return WeatherOutcome::Failed(format!("amap: {e}")),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) 和风实时天气(成功才计数)
|
// 3) 和风实时天气(成功才计数)
|
||||||
@ -273,7 +304,7 @@ impl WeatherService {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.cache
|
.cache
|
||||||
.insert(ip.to_string(), CacheEntry { at: Instant::now(), data: data.clone() });
|
.insert(cache_key, CacheEntry { at: Instant::now(), data: data.clone() });
|
||||||
|
|
||||||
WeatherOutcome::Fresh(data)
|
WeatherOutcome::Fresh(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,34 @@
|
|||||||
# 起始页:天气 + 定位(已实现)
|
# 起始页:天气 + 定位(已实现)
|
||||||
|
|
||||||
导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。
|
导航起始页问候语下方展示「天气图标 · 城市 · 天气 · 气温」。
|
||||||
**城市来自高德 IP 定位,天气来自和风实时天气。** 密钥只在后端,前端不可见。
|
**定位优先用前端高德 `AMap.Geolocation`(能 GPS 就 GPS),失败退回后端高德 IP 定位;
|
||||||
|
天气始终来自和风实时天气(私钥只在后端)。**
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
浏览器 → GET /api/web/weather (后端)
|
浏览器
|
||||||
├─ 高德 /v3/ip?ip=<访问者IP> → 城市名 + 城市中心坐标
|
├─ AMap.Geolocation 前端定位(JS API)
|
||||||
|
│ ├─ 浏览器原生定位(GPS/WiFi,精度高,**需 HTTPS**)→ 坐标 + 城市
|
||||||
|
│ └─ 失败/被拒 → 高德纯 IP 城市定位(getCityInfo)
|
||||||
|
│ ↓ 带 lon,lat,city
|
||||||
|
└─ GET /api/web/weather[?lon=&lat=&city=] (后端)
|
||||||
|
├─ 带了坐标 → 直接用,**不调高德**(省高德额度)
|
||||||
|
├─ 没带坐标 → 高德 /v3/ip?ip=<访问者IP> 兜底 → 城市名 + 城市中心坐标
|
||||||
└─ 和风 /v7/weather/now?location= → 天气文字 / 气温 / 图标码
|
└─ 和风 /v7/weather/now?location= → 天气文字 / 气温 / 图标码
|
||||||
```
|
```
|
||||||
|
|
||||||
- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存)、路由 `Server/src/routes/web.rs` 的 `/api/web/weather`。
|
> **为什么前端定位 + 后端兜底两套并存:** 前端原生定位(GPS)能到街道级、还能绕开
|
||||||
|
> 「数据中心 / CGNAT / 境外 IP 高德返回空」的痛点,但**浏览器原生定位在非 localhost 的
|
||||||
|
> HTTP 页面下被禁用**。当前线上是裸 HTTP,所以现阶段前端只会走它的 IP 退路(精度等同后端
|
||||||
|
> IP 定位);**等站点上了 HTTPS,GPS 会自动开始生效,无需改代码**。前端定位彻底失败时,
|
||||||
|
> 后端再用 REST Key 做一次 IP 定位作双保险。
|
||||||
|
|
||||||
|
- 后端:`Server/src/weather.rs`(服务 + 限流 + 缓存,`get()` 接收可选前端坐标)、
|
||||||
|
路由 `Server/src/routes/web.rs` 的 `/api/web/weather`(解析可选 `lon/lat/city`)。
|
||||||
- 前端:
|
- 前端:
|
||||||
- `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`(拉取,30 分钟刷新)
|
- `Client/src/lib/amap.ts`:加载高德 JSAPI + `AMap.Geolocation`,封装 `locate()`
|
||||||
|
- `Client/src/api/weather.ts`、`Client/src/hooks/useWeather.ts`(先定位再拉取,30 分钟刷新)
|
||||||
- `Client/src/components/StartPage.tsx`:统一持有天气状态,派生**昼夜背景**与**天气动效**,并把天气传给 `Greeting`
|
- `Client/src/components/StartPage.tsx`:统一持有天气状态,派生**昼夜背景**与**天气动效**,并把天气传给 `Greeting`
|
||||||
- `Client/src/components/start/Greeting.tsx`:展示「天气图标 · 城市 · 天气 · 气温」
|
- `Client/src/components/start/Greeting.tsx`:展示「天气图标 · 城市 · 天气 · 气温」
|
||||||
- `Client/src/components/start/WeatherIcon.tsx`:天气文字旁的静态图标
|
- `Client/src/components/start/WeatherIcon.tsx`:天气文字旁的静态图标
|
||||||
@ -24,16 +39,33 @@
|
|||||||
- **高德 300 次/天、和风 900 次/天**,各自独立计数,**北京时间 0 点归零**。
|
- **高德 300 次/天、和风 900 次/天**,各自独立计数,**北京时间 0 点归零**。
|
||||||
- 任一服务商额度用尽 → 接口返回 `{ ok:false, reason:"quota_exceeded", provider }`,
|
- 任一服务商额度用尽 → 接口返回 `{ ok:false, reason:"quota_exceeded", provider }`,
|
||||||
前端显示「天气请求次数已用完,明日 0 点恢复」。
|
前端显示「天气请求次数已用完,明日 0 点恢复」。
|
||||||
- **按访问者 IP 缓存 10 分钟**:前端每 30 分钟刷新一次,正常用量远低于额度。
|
- **缓存 10 分钟**:前端带坐标时按 `坐标` 聚合缓存,未带时按访问者 `IP` 缓存;
|
||||||
|
前端每 30 分钟刷新一次,正常用量远低于额度。
|
||||||
- 计数只在“真的调用了上游”时增加(命中缓存不计数)。
|
- 计数只在“真的调用了上游”时增加(命中缓存不计数)。
|
||||||
|
**前端已定位(带了坐标)时不调高德、也不消耗高德额度**,只消耗和风额度。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
后端启动时读取(缺高德 Key 或和风 JWT 任一项时接口返回 `not_configured`,前端静默不展示):
|
### 前端(Vite,`Client/.env.local`,**不进仓库**)
|
||||||
|
|
||||||
|
高德 **Web端(JS API)** 密钥,给 `AMap.Geolocation` 前端定位用。与后端 `AMAP_KEY` 是**两套**。
|
||||||
|
不配则前端不定位、直接由后端 IP 兜底(旧行为,不会报错)。Vite 在 dev 与 build 时都会加载
|
||||||
|
`.env.local`,故本地构建出的 `dist` 会内置此 Key(JS Key 本就暴露在前端,靠高德控制台
|
||||||
|
**「安全域名」白名单**约束,不是机密)。
|
||||||
|
|
||||||
| 变量 | 必填 | 说明 |
|
| 变量 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `AMAP_KEY` | 是 | 高德 **Web服务(REST)** Key |
|
| `VITE_AMAP_JS_KEY` | 否 | 高德 **Web端(JS API)** Key |
|
||||||
|
| `VITE_AMAP_JS_CODE` | 否 | 高德 **安全密钥(securityJsCode)**;JSAPI 2.0 起与 Key 成对必需,缺它定位报 `INVALID_USER_SCODE` |
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
后端启动时读取(缺和风 JWT 时接口返回 `not_configured`,前端静默不展示;
|
||||||
|
`AMAP_KEY` 仅 IP 兜底路径需要,前端总能定位时可不配):
|
||||||
|
|
||||||
|
| 变量 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `AMAP_KEY` | 兜底必填 | 高德 **Web服务(REST)** Key,仅前端定位失败的 IP 兜底路径用 |
|
||||||
| `QWEATHER_HOST` | 是 | 和风**专属 API Host**(控制台给的,如 `https://xxxx.qweatherapi.com`) |
|
| `QWEATHER_HOST` | 是 | 和风**专属 API Host**(控制台给的,如 `https://xxxx.qweatherapi.com`) |
|
||||||
| `QWEATHER_PROJECT_ID` | 是 | 和风**项目 ID** → JWT 的 `sub` |
|
| `QWEATHER_PROJECT_ID` | 是 | 和风**项目 ID** → JWT 的 `sub` |
|
||||||
| `QWEATHER_KEY_ID` | 是 | 和风**凭据 ID** → JWT 头的 `kid` |
|
| `QWEATHER_KEY_ID` | 是 | 和风**凭据 ID** → JWT 头的 `kid` |
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user