diff --git a/1114/app/components/weather/WeatherCard.tsx b/1114/app/components/weather/WeatherCard.tsx index dc1648d..1d2383a 100644 --- a/1114/app/components/weather/WeatherCard.tsx +++ b/1114/app/components/weather/WeatherCard.tsx @@ -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 ? (
+
) : ( @@ -38,3 +49,4 @@ export function WeatherCard() { ); } + diff --git a/1114/app/components/weather/WeatherSearchForm.tsx b/1114/app/components/weather/WeatherSearchForm.tsx index b9db5c1..b4b5a83 100644 --- a/1114/app/components/weather/WeatherSearchForm.tsx +++ b/1114/app/components/weather/WeatherSearchForm.tsx @@ -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) => { - setCity(event.target.value); - setInputError(''); -}; -//表单提交验证 -const handleSubmit = async (e: FormEvent) => { +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) => { + setCity(e.target.value); + if (inputError) setInputError(''); + }; + + const handleSubmit = async (e: FormEvent) => { 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 { - try { - const response = await apiClient.get( - '/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 ( +
+
+
+
+ +
+ +
+ + {inputError && ( +
+ +

{inputError}

+
+ )} + +
+ + + {currentWeather && ( + + )} +
+
+
+ ); +} + diff --git a/1114/app/store/weatherStore.ts b/1114/app/store/weatherStore.ts index e69de29..ed40d15 100644 --- a/1114/app/store/weatherStore.ts +++ b/1114/app/store/weatherStore.ts @@ -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; // 城市名 -> 缓存数据 + 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;//查找缓存 无:查找 + refreshWeather: () => Promise;//刷新 缓存 +} + + +/** + * useWeatherStore - 天气应用的全局状态管理 + * + * Zustand 的核心概念: + * 1. create() 函数创建 store + * 2. set() 函数更新状态 + * 3. get() 函数读取当前状态 + * 4. persist 中间件实现数据持久化 + * + * 缓存策略: + * - 使用 Map 存储城市天气数据 + * - 每条数据包含时间戳,10分钟后过期 + * - 优先从缓存读取,减少 API 调用 + */ +export const useWeatherStore = create()( + 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); + } + }, + } + ) +); + +