This commit is contained in:
qiuchenfan 2025-11-17 11:13:06 +08:00
parent 0c81f64e0f
commit 8344bdf14c
3 changed files with 415 additions and 97 deletions

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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);
}
},
}
)
);