1117
This commit is contained in:
parent
0c81f64e0f
commit
8344bdf14c
|
|
@ -1,3 +1,13 @@
|
|||
/**
|
||||
* WeatherCard.tsx - 统一的天气卡片组件
|
||||
* 集成搜索表单、天气展示、搜索历史于一体
|
||||
*
|
||||
* 学习要点:
|
||||
* - 直接使用 store,无需通过 props 传递
|
||||
* - 组件更加独立和可复用
|
||||
* - 减少 prop drilling
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { WeatherSearchForm } from './WeatherSearchForm';
|
||||
import { WeatherDisplay } from './WeatherDisplay';
|
||||
|
|
@ -17,6 +27,7 @@ export function WeatherCard() {
|
|||
{currentWeather ? (
|
||||
<div className="p-6">
|
||||
<WeatherDisplay />
|
||||
|
||||
<WeatherDetailsGrid />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -38,3 +49,4 @@ export function WeatherCard() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,105 @@
|
|||
//天气搜索表单组件
|
||||
import { cn } from "@/lib/utils";
|
||||
import React ,{useState,type FormEvent,type ChangeEvent, use} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input} from "../ui/input";
|
||||
import {useWeatherStore} from "@/store/weatherStore";
|
||||
import { AlertCircle, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
|
||||
//城市输入验证搜索刷新
|
||||
export function WeatherSearchForm() {
|
||||
const {isLoading,currentWeather,setCurrentWeather,refreshWeather,searchWeather} = useWeatherStore();
|
||||
const [city, setCity] = useState('');
|
||||
const [inputError, setInputError] = useState('');
|
||||
//输入框内容
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCity(event.target.value);
|
||||
setInputError('');
|
||||
};
|
||||
//表单提交验证
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
import React, { useState, type FormEvent, type ChangeEvent } from 'react';
|
||||
import { Search, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWeatherStore } from '@/store/weatherStore';
|
||||
|
||||
export function WeatherSearchForm() {
|
||||
const { isLoading, currentWeather, searchWeather, refreshWeather } = useWeatherStore();
|
||||
const [city, setCity] = useState('');
|
||||
const [inputError, setInputError] = useState('');
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setCity(e.target.value);
|
||||
if (inputError) setInputError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = city.trim();
|
||||
|
||||
//创建axios实例 统一请求头
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
//请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
// 在发送请求之前做些什么
|
||||
config.params = {
|
||||
...config.params,
|
||||
access_key: API_KEY,
|
||||
};
|
||||
//打印请求信息
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('API Request:', config.method, config.url, config.params);
|
||||
if (!trimmed) {
|
||||
setInputError('请输入城市名称');
|
||||
return;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 对请求错误做些什么
|
||||
console.error('Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
// 添加响应拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
//状态为200时的错误
|
||||
if (response.data && response.data.error ) {
|
||||
const error = response.data.error;
|
||||
console.error('API Error:', error);
|
||||
throw new Error(error.info || '请求失败');
|
||||
}//打印响应
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('API Response:', response.status, response.data);
|
||||
if (trimmed.length < 2) {
|
||||
setInputError('城市名称至少需要2个字符');
|
||||
return;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error:AxiosError) => {
|
||||
//统一错误处理
|
||||
console.error('Response Error:', error.message);
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
switch (status) {
|
||||
case 404:
|
||||
return Promise.reject(new Error('未找到该城市,请检查城市名称'));
|
||||
case 401:
|
||||
return Promise.reject(new Error('API认证失败,请检查API密钥'));
|
||||
case 403:
|
||||
return Promise.reject(new Error('访问被拒绝,请稍后再试'));
|
||||
case 429:
|
||||
return Promise.reject(new Error('请求次数过多,请稍后再试'));
|
||||
default:
|
||||
return Promise.reject(new Error('服务器错误,请稍后再试'));
|
||||
}
|
||||
}else if(error.request){
|
||||
return Promise.reject(new Error('网络连接失败,请检查网络状态'));
|
||||
}else {
|
||||
return Promise.reject(new Error('请求配置错误'));
|
||||
}
|
||||
}
|
||||
);
|
||||
//WeatherAPI
|
||||
export class WeatherAPI {
|
||||
static async getCurrentWeather(city: string): Promise<WeatherData> {
|
||||
try {
|
||||
const response = await apiClient.get<WeatherData>(
|
||||
'/current',
|
||||
{params : {query: city },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!/^[\u4e00-\u9fa5a-zA-Z\s-]+$/.test(trimmed)) {
|
||||
setInputError('城市名称包含无效字符');
|
||||
return;
|
||||
}
|
||||
export default apiClient;
|
||||
|
||||
await searchWeather(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 border-b border-slate-200/50 dark:border-slate-700/50">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<Search className="w-5 h-5 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={city}
|
||||
onChange={handleInputChange}
|
||||
placeholder="搜索城市,如北京、上海、London..."
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'h-12 pl-12 pr-4 text-base rounded-xl',
|
||||
'bg-slate-50/50 dark:bg-slate-900/50',
|
||||
'focus:bg-white dark:focus:bg-slate-900',
|
||||
'transition-all duration-200',
|
||||
inputError && 'border-red-300 dark:border-red-700'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputError && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-50/80 dark:bg-red-900/20 animate-in slide-in-from-top-1 fade-in">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 mt-0.5" />
|
||||
<p className="text-xs text-red-600 dark:text-red-400 font-medium">{inputError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !city.trim()}
|
||||
className="flex-1 h-11 rounded-xl bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 disabled:opacity-50 transition-all duration-300"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="ml-2">搜索中</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="ml-2">搜索</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{currentWeather && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={refreshWeather}
|
||||
disabled={isLoading}
|
||||
className="h-11 px-4 rounded-xl"
|
||||
title="刷新数据"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,305 @@
|
|||
/**
|
||||
* weatherStore.ts - Zustand 全局状态管理
|
||||
*
|
||||
* 学习要点:
|
||||
* 1. Zustand 的基本使用方法
|
||||
* 2. 如何创建和使用 store
|
||||
* 3. 状态更新的不可变性(immutability)
|
||||
* 4. TypeScript 与 Zustand 的结合
|
||||
* 5. 本地存储(localStorage)的集成
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { WeatherAPI } from '@/services/weatherApi';
|
||||
|
||||
/**
|
||||
* 缓存过期时间(毫秒)
|
||||
* 10分钟后缓存过期,需要重新获取
|
||||
*/
|
||||
const CACHE_EXPIRY = 10 * 60 * 1000;
|
||||
/**
|
||||
* WeatherCache - 天气数据缓存项
|
||||
*/
|
||||
export interface Location {
|
||||
name: string; // 城市名称
|
||||
country: string; // 国家代码
|
||||
region: string; // 地区/省份
|
||||
lat: string; // 纬度
|
||||
lon: string; // 经度
|
||||
timezone_id: string; // 时区ID
|
||||
localtime: string; // 本地时间(YYYY-MM-DD HH:mm)
|
||||
localtime_epoch: number; // 本地时间Unix时间戳
|
||||
utc_offset: string; // UTC偏移量
|
||||
}
|
||||
|
||||
/**
|
||||
* Current - 当前天气数据接口
|
||||
* WeatherStack 返回的当前天气信息
|
||||
*/
|
||||
export interface Current {
|
||||
observation_time: string; // 观测时间
|
||||
temperature: number; // 温度(摄氏度)
|
||||
weather_code: number; // 天气代码
|
||||
weather_icons: string[]; // 天气图标URL数组
|
||||
weather_descriptions: string[]; // 天气描述数组
|
||||
wind_speed: number; // 风速(km/h)
|
||||
wind_degree: number; // 风向角度
|
||||
wind_dir: string; // 风向(如 "N", "NE")
|
||||
pressure: number; // 气压(mb)
|
||||
precip: number; // 降水量(mm)
|
||||
humidity: number; // 湿度(%)
|
||||
cloudcover: number; // 云量(%)
|
||||
feelslike: number; // 体感温度
|
||||
uv_index: number; // 紫外线指数
|
||||
visibility: number; // 能见度(km)
|
||||
is_day: string; // 是否白天("yes" / "no")
|
||||
}
|
||||
|
||||
/**
|
||||
* WeatherData - 完整的天气数据接口
|
||||
* 这是 WeatherStack API 返回的完整数据结构
|
||||
*/
|
||||
export interface WeatherData {
|
||||
request?: {
|
||||
type: string;
|
||||
query: string;
|
||||
language: string;
|
||||
unit: string;
|
||||
};
|
||||
location: Location;
|
||||
current: Current;
|
||||
}
|
||||
|
||||
export interface WeatherCache {
|
||||
data: WeatherData;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WeatherStore - Zustand store 的状态接口
|
||||
* 定义了全局状态管理的完整类型
|
||||
*/
|
||||
export interface WeatherStore {
|
||||
// 状态
|
||||
currentWeather: WeatherData | null;
|
||||
weatherCache: Map<string, WeatherCache>; // 城市名 -> 缓存数据
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
setCurrentWeather: (weather: WeatherData | null) => void;
|
||||
getWeatherFromCache: (city: string) => WeatherData | null;
|
||||
cacheWeather: (city: string, weather: WeatherData) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
reset: () => void;
|
||||
|
||||
// API Actions
|
||||
searchWeather: (city: string) => Promise<void>;//查找缓存 无:查找
|
||||
refreshWeather: () => Promise<void>;//刷新 缓存
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* useWeatherStore - 天气应用的全局状态管理
|
||||
*
|
||||
* Zustand 的核心概念:
|
||||
* 1. create() 函数创建 store
|
||||
* 2. set() 函数更新状态
|
||||
* 3. get() 函数读取当前状态
|
||||
* 4. persist 中间件实现数据持久化
|
||||
*
|
||||
* 缓存策略:
|
||||
* - 使用 Map 存储城市天气数据
|
||||
* - 每条数据包含时间戳,10分钟后过期
|
||||
* - 优先从缓存读取,减少 API 调用
|
||||
*/
|
||||
export const useWeatherStore = create<WeatherStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// ========== 状态定义 ==========
|
||||
|
||||
/**
|
||||
* currentWeather - 当前天气数据
|
||||
*/
|
||||
currentWeather: null,
|
||||
|
||||
/**
|
||||
* weatherCache - 天气数据缓存
|
||||
* Key: 城市名(小写)
|
||||
* Value: { data, timestamp }
|
||||
*/
|
||||
weatherCache: new Map(),
|
||||
|
||||
/**
|
||||
* isLoading - 加载状态
|
||||
*/
|
||||
isLoading: false,
|
||||
|
||||
/**
|
||||
* error - 错误信息
|
||||
*/
|
||||
error: null,
|
||||
|
||||
// ========== Action 方法定义 ==========
|
||||
|
||||
/**
|
||||
* setCurrentWeather - 设置当前天气
|
||||
*
|
||||
* @param weather - 新的天气数据
|
||||
*/
|
||||
setCurrentWeather: (weather) =>
|
||||
set({ currentWeather: weather }),
|
||||
|
||||
/**
|
||||
* getWeatherFromCache - 从缓存获取天气数据
|
||||
*
|
||||
* @param city - 城市名
|
||||
* @returns 缓存的天气数据,如果过期或不存在则返回 null
|
||||
*
|
||||
* 学习要点:
|
||||
* - 使用 get() 读取当前状态
|
||||
* - 缓存过期检查
|
||||
* - Zustand 的纯函数特性
|
||||
*/
|
||||
getWeatherFromCache: (city) => {
|
||||
const cache = get().weatherCache.get(city.toLowerCase());
|
||||
|
||||
if (!cache) return null;
|
||||
|
||||
// 检查缓存是否过期
|
||||
const isExpired = Date.now() - cache.timestamp > CACHE_EXPIRY;
|
||||
return isExpired ? null : cache.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* cacheWeather - 缓存天气数据
|
||||
*
|
||||
* @param city - 城市名
|
||||
* @param weather - 天气数据
|
||||
*
|
||||
* 学习要点:
|
||||
* - Map 的不可变更新
|
||||
* - 使用 new Map() 创建新实例确保响应式更新
|
||||
*/
|
||||
cacheWeather: (city, weather) =>
|
||||
set((state) => {
|
||||
const newCache = new Map(state.weatherCache);
|
||||
newCache.set(city.toLowerCase(), {
|
||||
data: weather,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return { weatherCache: newCache };
|
||||
}),
|
||||
|
||||
/**
|
||||
* setLoading - 设置加载状态
|
||||
*/
|
||||
setLoading: (loading) =>
|
||||
set({ isLoading: loading }),
|
||||
|
||||
/**
|
||||
* setError - 设置错误信息
|
||||
*/
|
||||
setError: (error) =>
|
||||
set({ error, isLoading: false }),
|
||||
|
||||
/**
|
||||
* reset - 重置所有状态
|
||||
*/
|
||||
reset: () =>
|
||||
set({
|
||||
currentWeather: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
// 保留缓存数据
|
||||
}),
|
||||
|
||||
/**
|
||||
* searchWeather - 搜索天气(使用缓存策略)
|
||||
*
|
||||
* @param city - 城市名
|
||||
*
|
||||
* 学习要点:
|
||||
* - 在 store 中封装 API 调用逻辑
|
||||
* - 优先使用缓存,减少 API 调用
|
||||
* - 统一管理 loading 和 error 状态
|
||||
*/
|
||||
searchWeather: async (city: string) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// 1. 先从缓存获取
|
||||
const cachedWeather = get().getWeatherFromCache(city);
|
||||
if (cachedWeather) {
|
||||
set({ currentWeather: cachedWeather, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 缓存未命中,调用 API
|
||||
const weatherData = await WeatherAPI.getCurrentWeather(city);
|
||||
|
||||
// 3. 更新当前天气并缓存
|
||||
set({ currentWeather: weatherData, isLoading: false });
|
||||
get().cacheWeather(city, weatherData);
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : '获取天气数据失败',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* refreshWeather - 刷新当前天气(强制刷新,忽略缓存)
|
||||
*
|
||||
* 学习要点:
|
||||
* - 强制刷新,不使用缓存
|
||||
* - 更新缓存中的数据
|
||||
*/
|
||||
refreshWeather: async () => {
|
||||
const currentWeather = get().currentWeather;
|
||||
if (!currentWeather) return;
|
||||
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
const city = currentWeather.location.name;
|
||||
const weatherData = await WeatherAPI.getCurrentWeather(city);
|
||||
|
||||
// 更新当前天气和缓存
|
||||
set({ currentWeather: weatherData, isLoading: false });
|
||||
get().cacheWeather(city, weatherData);
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : '刷新失败',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
/**
|
||||
* persist 中间件配置
|
||||
*
|
||||
* 学习要点:
|
||||
* - 持久化缓存数据到 localStorage
|
||||
* - Map 需要序列化为数组存储
|
||||
*/
|
||||
name: 'weather-storage',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({
|
||||
weatherCache: Array.from(state.weatherCache.entries()),
|
||||
}),
|
||||
// 反序列化:将数组转回 Map
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state && Array.isArray(state.weatherCache)) {
|
||||
state.weatherCache = new Map(state.weatherCache as any);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue