diff --git a/app/components/WeatherDetailsGird.tsx b/app/components/WeatherDetailsGird.tsx index 2287c34..30bf498 100644 --- a/app/components/WeatherDetailsGird.tsx +++ b/app/components/WeatherDetailsGird.tsx @@ -1,3 +1,6 @@ +import { Cloud, Droplets, Gauge, ThermometerSun, Wind } from "lucide-react"; +import { useWeatherStore } from "@/store/weatherStore"; + interface WeatherDetailsGridProps { icon: React.ReactNode; label: string; @@ -5,3 +8,53 @@ interface WeatherDetailsGridProps { subtitle?: string; } +function WeatherInfoItem({ icon, label, value, subtitle }: WeatherDetailsGridProps) { + return( +
+
+
+ {icon} +
+
+

{label}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+
+ ) +} + +export function WeatherDetailsGrid(){ + const {currentWeather} = useWeatherStore(); + if(!currentWeather) return null; + return( +
+
+ } label="湿度" value={`${currentWeather.current.humidity}%`} /> + } label="风速" value={`${currentWeather.current.wind_speed} m/s`} /> + } label="气压" value={`${currentWeather.current.pressure} hPa`} /> + } label="云量" value={`${currentWeather.current.cloudcover}%`} /> + } label="紫外线" value={`${currentWeather.current.uv_index} UV`} /> +
+
+
+
+ + 降水量:\ + + {currentWeather.current.precip} mm + + +
+ + {currentWeather.current.is_day === "yes" ? "白天" : "夜晚"} + +
+

+ 观测时间: {currentWeather.current.observation_time} +

+
+
+ ) +} \ No newline at end of file diff --git a/app/components/WeatherSearchForm.tsx b/app/components/WeatherSearchForm.tsx new file mode 100644 index 0000000..04dff4f --- /dev/null +++ b/app/components/WeatherSearchForm.tsx @@ -0,0 +1,102 @@ +import React, { useState, type FormEvent, type ChangeEvent } from 'react'; +import { Search, Loader2, AlertCircle, RefreshCw } from 'lucide-react'; +import { Button } from '@/ui/button'; +import { Input } from '@/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 handleFormSubmit = async (e: FormEvent) => { + e.preventDefault(); + const trimmedCity = city.trim(); + if (!trimmedCity) { + setInputError('请输入城市名称'); + return; + } + if (!trimmedCity) { + setInputError('请输入有效的城市名称'); + return; + } + + if (trimmedCity.length < 2) { + setInputError('请输入至少2个字符'); + return; + } + + if (!/^[a-zA-Z\s]+$/.test(trimmedCity)) { + setInputError('请输入字母和空格'); + return; + } + await searchWeather(trimmedCity); + } + + return ( +
+
+
+
+ +
+ setCity(e.target.value)} + placeholder="请输入城市名称" + disabled={isLoading} + className={cn('pl-10 pr-4 py-2 w-full', inputError && 'border-red-500')} + /> +
+ {inputError && ( +
+ +

{inputError}

+
+ + + ) + } + +
+ + + {currentWeather && ( + + )} +
+ + +
+
+ + + + ) + } + +} \ No newline at end of file diff --git a/app/lib/utils.ts b/app/lib/utils.ts index bd0c391..96a51f4 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -4,3 +4,12 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export const formatTemperature = ( + temp: number, + unit: 'celsius' | 'fahrenheit' = 'celsius' +): string => { + const value = unit === 'fahrenheit' ? (temp * 9) / 5 + 32 : temp; + const symbol = unit === 'fahrenheit' ? '°F' : '°C'; + return `${Math.round(value)}${symbol}`; +}; \ No newline at end of file diff --git a/app/routes.ts b/app/routes.ts index 102b402..39a1749 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,3 +1,3 @@ -import { type RouteConfig, index } from "@react-router/dev/routes"; +import { type RouteConfig, index,route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [index("routes/home.tsx"),route('weather',"routes/weather.tsx")] satisfies RouteConfig; diff --git a/app/services/weatherApi.ts b/app/services/weatherApi.ts new file mode 100644 index 0000000..d5b9477 --- /dev/null +++ b/app/services/weatherApi.ts @@ -0,0 +1,80 @@ +import axios,{type AxiosInstance,AxiosError} from 'axios'; +import { type WeatherData } from '@/store/weatherStore'; + + +const API_KEY = '0c4679ac2decfe6a756aa09e61f42dc1'; +const BASE_URL = 'http://api.weatherstack.com'; + +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 ?.toUpperCase(), config.url); + } + return config; + }, + (error) => { + console.error('Request Error:' ,error); + return Promise.reject(error); + } +); + +apiClient.interceptors.response.use( + (response) => { + 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.config.url,response.data); + } + return response; + }, + (error :AxiosError)=>{ + + console.error('Response Error:',error.message); + if (error.response) { + const status = error.response.status; + if (status === 404) { + throw new Error('未找到该城市的天气信息'); + } else if (status === 401) { + throw new Error('API认证失败,请检查API'); + } else if (status === 403) { + throw new Error('请检查API密钥是否正确'); + } + } + else if (error.request) { + throw new Error('网络连接失败'); + } + else { + return (('配置错误')) + } + } + + +); + +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; +} +} + +} +export default WeatherApi; \ No newline at end of file diff --git a/app/store/weatherStore.ts b/app/store/weatherStore.ts index 5c9f8a1..8754ff9 100644 --- a/app/store/weatherStore.ts +++ b/app/store/weatherStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import {persist} from 'zustand/middleware' +import {persist,createJSONStorage} from 'zustand/middleware' const CACHE_EXPIRY = 10 * 60 * 1000; export interface Location { @@ -71,23 +71,68 @@ export interface WeatherStore { } -export const useWeatherStore = create()(persist((set,get)=>({currentWeather:null,weatherCache:new Map(),isLoading:false,error:null, - setCurrentWeather:(weather)=>set({currentWeather:weather}), - getWeatherFromCache:(city)=>{ - const cashe = get().weatherCache.get(city.toLowerCase()); - if(!cashe)return null; - const isExpired = Date.now()-cashe.timestamp>CACHE_EXPIRY; - return isExpired?null:cashe.data; - }, - cacheWeather:(city,weather)=>set((state)=>{ - const newCache = new Map(state.weatherCache); - newCache.set(city.toLowerCase(),{data:weather,timestamp:Date.now()}); - return{weatherCache:newCache} - }), - setLoading:(loading)=>set({error}) -}) - -)) +export const useWeatherStore = create()( + persist( + (set,get)=>({ + currentWeather:null,weatherCache:new Map(),isLoading:false,error:null, + setCurrentWeather:(weather)=>set({currentWeather:weather}), + getWeatherFromCache:(city)=>{ + const cashe = get().weatherCache.get(city.toLowerCase()); + if(!cashe)return null; + const isExpired = Date.now()-cashe.timestamp>CACHE_EXPIRY; + return isExpired?null:cashe.data; + }, + cacheWeather:(city,weather)=>set((state)=>{ + const newCache = new Map(state.weatherCache); + newCache.set(city.toLowerCase(),{data:weather,timestamp:Date.now()}); + return{weatherCache:newCache} + }), + setLoading:(loading)=>set({isLoading:loading}), + setError:(error)=>set({error}), + reset:()=>set({currentWeather:null,weatherCache:new Map(),isLoading:false,error:null}), + searchWeather:async(city:string)=>{ + try{ + set({isLoading:true,error:null}); + const cachedWeather = get().getWeatherFromCache(city); + if(cachedWeather){ + set({currentWeather:cachedWeather,isLoading:false}); + return; + } + const WeatherData = await WeatherAPI.getcurrentWeather(city); + set({currentWeather:WeatherData,isLoading:false}); + get().cacheWeather(city,WeatherData); + }catch(error){ + set({error:'获取失败',isLoading:false}); + } + }, + 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(error){ + set({error:'刷新失败',isLoading:false}); + } + } + }), + { + name:'weather-strorage', + storage:createJSONStorage(()=>localStorage), + partialize:(state)=>({ + weatherCache:Array.from(state.weatherCache.entries()) + }), + onRehydrateStorage:(state)=>{ + if(state && Array.isArray(state.weatherCache)){ + state.weatherCache = new Map(state.weatherCache as any); + } + } + } + ) +) diff --git a/package.json b/package.json index 653642a..84ef151 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "react-router-serve ./build/server/index.js", "typecheck": "react-router typegen && tsc" }, + "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2faab6..86a9fdf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -868,56 +868,67 @@ packages: resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.2': resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.2': resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.2': resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.2': resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.53.2': resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.53.2': resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.2': resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.2': resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.2': resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.2': resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.53.2': resolution: {integrity: sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==} @@ -982,24 +993,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.17': resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.17': resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.17': resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.17': resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} @@ -1686,24 +1701,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}