Day-30-项目6-天气查询应用

Day 30: 项目6-天气查询应用

🎯 项目概述

我们将综合运用前29天学到的所有知识,构建一个功能完整、界面精美的天气查询应用。这是编程入门教程的终极项目,也是你JavaScript开发能力的综合检验。

项目特点

  • 真实API集成:使用OpenWeatherMap API获取实时天气
  • 完整CRUD操作:城市增删改查
  • 本地存储:保存偏好设置和历史记录
  • 异步编程:fetch + async/await
  • 响应式设计:适配桌面和移动设备
  • 动画效果:流畅的UI交互
  • 错误处理:友好的错误提示
  • 代码规范:模块化、可维护

🎨 项目预览

功能清单

  1. 实时天气查询

    • 支持城市名称搜索
    • 显示当前温度、天气状况
    • 湿度、风速、气压等详细信息
    • 天气图标动态显示
  2. 多城市管理

    • 添加/删除常用城市
    • 城市列表快速切换
    • 搜索历史记录
  3. 数据持久化

    • LocalStorage保存设置
    • 自动恢复上次状态
  4. 用户体验

    • 加载动画
    • 错误提示
    • 回车快捷搜索
    • 防抖优化

📁 项目结构

weather-app/
├── index.html          # 主页面
├── css/
│   ├── styles.css      # 主样式
│   └── animations.css  # 动画效果
├── js/
│   ├── api.js          # API请求封装
│   ├── storage.js      # 本地存储管理
│   ├── ui.js           # UI交互逻辑
│   └── app.js          # 主应用逻辑
└── assets/
    └── icons/          # 天气图标

💻 完整代码实现

1. HTML结构 (index.html)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>天气查询应用</title>
  <link rel="stylesheet" href="css/styles.css">
  <link rel="stylesheet" href="css/animations.css">
</head>
<body>
  <div class="container">
    <!-- 头部 -->
    <header class="header">
      <h1 class="title">🌤️ 天气查询</h1>
      <p class="subtitle">实时天气信息查询</p>
    </header>

    <!-- 搜索区域 -->
    <section class="search-section">
      <div class="search-box">
        <input
          type="text"
          id="cityInput"
          class="search-input"
          placeholder="输入城市名称..."
          autocomplete="off"
        >
        <button id="searchBtn" class="search-btn">
          <span class="btn-text">查询</span>
          <span class="btn-icon">🔍</span>
        </button>
      </div>
      <div class="search-tips">
        💡 提示:支持中英文城市名,如"北京"、"Tokyo"
      </div>
    </section>

    <!-- 加载动画 -->
    <div id="loading" class="loading hidden">
      <div class="spinner"></div>
      <p>加载中...</p>
    </div>

    <!-- 错误提示 -->
    <div id="error" class="error-message hidden"></div>

    <!-- 天气信息卡片 -->
    <section id="weatherCard" class="weather-card hidden">
      <!-- 城市和天气图标 -->
      <div class="weather-header">
        <div>
          <h2 class="city-name" id="cityName">--</h2>
          <p class="weather-date" id="weatherDate">--</p>
        </div>
        <div class="weather-icon" id="weatherIcon">🌡️</div>
      </div>

      <!-- 温度 -->
      <div class="temperature-section">
        <span class="temperature" id="temperature">--°</span>
        <span class="weather-description" id="description">--</span>
      </div>

      <!-- 详细信息网格 -->
      <div class="weather-details">
        <div class="detail-item">
          <div class="detail-icon">💧</div>
          <div class="detail-info">
            <div class="detail-label">湿度</div>
            <div class="detail-value" id="humidity">--%</div>
          </div>
        </div>

        <div class="detail-item">
          <div class="detail-icon">💨</div>
          <div class="detail-info">
            <div class="detail-label">风速</div>
            <div class="detail-value" id="windSpeed">-- m/s</div>
          </div>
        </div>

        <div class="detail-item">
          <div class="detail-icon">🌡️</div>
          <div class="detail-info">
            <div class="detail-label">体感温度</div>
            <div class="detail-value" id="feelsLike">--°</div>
          </div>
        </div>

        <div class="detail-item">
          <div class="detail-icon">🔵</div>
          <div class="detail-info">
            <div class="detail-label">气压</div>
            <div class="detail-value" id="pressure">-- hPa</div>
          </div>
        </div>

        <div class="detail-item">
          <div class="detail-icon">👁️</div>
          <div class="detail-info">
            <div class="detail-label">能见度</div>
            <div class="detail-value" id="visibility">-- km</div>
          </div>
        </div>

        <div class="detail-item">
          <div class="detail-icon">☁️</div>
          <div class="detail-info">
            <div class="detail-label">云量</div>
            <div class="detail-value" id="clouds">--%</div>
          </div>
        </div>
      </div>

      <!-- 操作按钮 -->
      <div class="weather-actions">
        <button id="saveCityBtn" class="action-btn">
          ⭐ 保存城市
        </button>
        <button id="refreshBtn" class="action-btn">
          🔄 刷新
        </button>
      </div>
    </section>

    <!-- 已保存的城市 -->
    <section class="saved-cities-section">
      <h3 class="section-title">📍 已保存的城市</h3>
      <div id="savedCities" class="saved-cities-list">
        <p class="empty-message">暂无保存的城市</p>
      </div>
    </section>

    <!-- 搜索历史 -->
    <section class="history-section">
      <h3 class="section-title">📜 搜索历史</h3>
      <div id="searchHistory" class="history-list">
        <p class="empty-message">暂无搜索历史</p>
      </div>
      <button id="clearHistoryBtn" class="clear-btn">
        🗑️ 清空历史
      </button>
    </section>

    <!-- 页脚 -->
    <footer class="footer">
      <p>数据来源:<a href="https://openweathermap.org/" target="_blank">OpenWeatherMap</a></p>
      <p>&copy; 2026 天气查询应用 | 编程入门教程 Day 30</p>
    </footer>
  </div>

  <!-- JavaScript模块 -->
  <script type="module" src="js/app.js"></script>
</body>
</html>

2. CSS样式 (css/styles.css)

/* ==================== 全局样式 ==================== */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --primary-color: #667eea;
  --secondary-color: #764ba2;
  --accent-color: #f093fb;
  --text-dark: #2d3748;
  --text-light: #718096;
  --bg-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  --card-bg: rgba(255, 255, 255, 0.95);
  --shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
  --radius: 16px;
}

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background: var(--bg-gradient);
  min-height: 100vh;
  padding: 20px;
  color: var(--text-dark);
  line-height: 1.6;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

/* ==================== 头部样式 ==================== */
.header {
  text-align: center;
  margin-bottom: 40px;
  color: white;
}

.title {
  font-size: 2.5em;
  margin-bottom: 10px;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}

.subtitle {
  font-size: 1.1em;
  opacity: 0.9;
}

/* ==================== 搜索区域 ==================== */
.search-section {
  background: var(--card-bg);
  padding: 30px;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  margin-bottom: 30px;
}

.search-box {
  display: flex;
  gap: 15px;
  margin-bottom: 15px;
}

.search-input {
  flex: 1;
  padding: 15px 20px;
  border: 2px solid #e2e8f0;
  border-radius: 10px;
  font-size: 16px;
  transition: all 0.3s;
}

.search-input:focus {
  outline: none;
  border-color: var(--primary-color);
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.search-btn {
  padding: 15px 30px;
  background: var(--bg-gradient);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s;
  display: flex;
  align-items: center;
  gap: 8px;
}

.search-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}

.search-btn:active {
  transform: translateY(0);
}

.search-tips {
  font-size: 14px;
  color: var(--text-light);
  text-align: center;
}

/* ==================== 加载和错误 ==================== */
.loading {
  text-align: center;
  padding: 40px;
  color: white;
}

.spinner {
  border: 4px solid rgba(255, 255, 255, 0.3);
  border-top: 4px solid white;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  animation: spin 1s linear infinite;
  margin: 0 auto 20px;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.error-message {
  background: #fed7d7;
  color: #c53030;
  padding: 15px 20px;
  border-radius: 10px;
  margin-bottom: 20px;
  border-left: 4px solid #c53030;
}

/* ==================== 天气卡片 ==================== */
.weather-card {
  background: var(--card-bg);
  padding: 40px;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  margin-bottom: 30px;
}

.weather-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

.city-name {
  font-size: 2em;
  color: var(--text-dark);
  margin-bottom: 5px;
}

.weather-date {
  color: var(--text-light);
  font-size: 0.9em;
}

.weather-icon {
  font-size: 4em;
}

.temperature-section {
  text-align: center;
  margin-bottom: 40px;
}

.temperature {
  font-size: 4em;
  font-weight: bold;
  background: var(--bg-gradient);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

.weather-description {
  font-size: 1.5em;
  color: var(--text-light);
  text-transform: capitalize;
  margin-top: 10px;
}

.weather-details {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}

.detail-item {
  background: #f7fafc;
  padding: 20px;
  border-radius: 12px;
  display: flex;
  align-items: center;
  gap: 15px;
  transition: transform 0.2s;
}

.detail-item:hover {
  transform: translateY(-3px);
  background: #edf2f7;
}

.detail-icon {
  font-size: 2em;
}

.detail-label {
  font-size: 0.85em;
  color: var(--text-light);
  margin-bottom: 5px;
}

.detail-value {
  font-size: 1.2em;
  font-weight: bold;
  color: var(--text-dark);
}

.weather-actions {
  display: flex;
  gap: 15px;
}

.action-btn {
  flex: 1;
  padding: 15px;
  background: var(--bg-gradient);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  font-weight: bold;
  cursor: pointer;
  transition: all 0.3s;
}

.action-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}

/* ==================== 已保存城市 ==================== */
.saved-cities-section,
.history-section {
  background: var(--card-bg);
  padding: 30px;
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  margin-bottom: 20px;
}

.section-title {
  font-size: 1.3em;
  margin-bottom: 20px;
  color: var(--text-dark);
}

.saved-cities-list,
.history-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.city-tag,
.history-tag {
  background: #edf2f7;
  padding: 10px 20px;
  border-radius: 25px;
  font-size: 14px;
  cursor: pointer;
  transition: all 0.3s;
  display: flex;
  align-items: center;
  gap: 8px;
}

.city-tag:hover,
.history-tag:hover {
  background: #e2e8f0;
  transform: translateY(-2px);
}

.city-tag .delete-btn {
  background: none;
  border: none;
  color: #e53e3e;
  cursor: pointer;
  font-size: 16px;
  padding: 0;
  margin-left: 5px;
}

.city-tag .delete-btn:hover {
  color: #c53030;
}

.empty-message {
  color: var(--text-light);
  font-style: italic;
}

.clear-btn {
  margin-top: 15px;
  padding: 10px 20px;
  background: #fed7d7;
  color: #c53030;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.3s;
}

.clear-btn:hover {
  background: #fc8181;
  color: white;
}

/* ==================== 页脚 ==================== */
.footer {
  text-align: center;
  color: white;
  padding: 20px;
  opacity: 0.9;
}

.footer a {
  color: white;
  text-decoration: underline;
}

/* ==================== 工具类 ==================== */
.hidden {
  display: none !important;
}

/* ==================== 响应式设计 ==================== */
@media (max-width: 600px) {
  .title {
    font-size: 2em;
  }

  .search-box {
    flex-direction: column;
  }

  .temperature {
    font-size: 3em;
  }

  .weather-details {
    grid-template-columns: 1fr;
  }

  .weather-actions {
    flex-direction: column;
  }
}

3. JavaScript模块 (js/api.js)

// ==================== API配置 ====================
const API_KEY = 'YOUR_OPENWEATHERMAP_API_KEY'; // 替换为你的API密钥
const API_BASE = 'https://api.openweathermap.org/data/2.5/weather';

// ==================== API请求封装 ====================

/**
 * 获取天气数据
 * @param {string} city - 城市名称
 * @returns {Promise<Object>} 天气数据
 */
export async function getWeather(city) {
  const url = `${API_BASE}?q=${encodeURIComponent(city)}&units=metric&lang=zh_cn&appid=${API_KEY}`;

  const response = await fetch(url);

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error('未找到该城市,请检查城市名称');
    } else if (response.status === 401) {
      throw new Error('API密钥无效');
    } else {
      throw new Error(`请求失败: ${response.status}`);
    }
  }

  return await response.json();
}

/**
 * 获取多个城市的天气
 * @param {string[]} cities - 城市列表
 * @returns {Promise<Object[]>} 天气数据数组
 */
export async function getWeatherForCities(cities) {
  const promises = cities.map(city => getWeather(city));
  return Promise.allSettled(promises);
}

4. JavaScript模块 (js/storage.js)

// ==================== 本地存储管理 ====================

const STORAGE_KEYS = {
  SAVED_CITIES: 'weatherapp_saved_cities',
  SEARCH_HISTORY: 'weatherapp_search_history',
  LAST_CITY: 'weatherapp_last_city'
};

/**
 * 保存城市列表
 * @param {string[]} cities - 城市列表
 */
export function saveCities(cities) {
  localStorage.setItem(STORAGE_KEYS.SAVED_CITIES, JSON.stringify(cities));
}

/**
 * 获取保存的城市列表
 * @returns {string[]} 城市列表
 */
export function getSavedCities() {
  const data = localStorage.getItem(STORAGE_KEYS.SAVED_CITIES);
  return data ? JSON.parse(data) : [];
}

/**
 * 添加城市到保存列表
 * @param {string} city - 城市名称
 */
export function addCity(city) {
  const cities = getSavedCities();
  if (!cities.includes(city)) {
    cities.push(city);
    saveCities(cities);
  }
}

/**
 * 从保存列表中删除城市
 * @param {string} city - 城市名称
 */
export function removeCity(city) {
  const cities = getSavedCities().filter(c => c !== city);
  saveCities(cities);
}

/**
 * 保存搜索历史
 * @param {string} city - 城市名称
 */
export function addToHistory(city) {
  const history = getSearchHistory();
  // 移除重复项
  const filtered = history.filter(c => c !== city);
  // 添加到开头
  filtered.unshift(city);
  // 限制数量
  const limited = filtered.slice(0, 10);
  localStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(limited));
}

/**
 * 获取搜索历史
 * @returns {string[]} 搜索历史
 */
export function getSearchHistory() {
  const data = localStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
  return data ? JSON.parse(data) : [];
}

/**
 * 清空搜索历史
 */
export function clearHistory() {
  localStorage.removeItem(STORAGE_KEYS.SEARCH_HISTORY);
}

/**
 * 保存最后查询的城市
 * @param {string} city - 城市名称
 */
export function saveLastCity(city) {
  localStorage.setItem(STORAGE_KEYS.LAST_CITY, city);
}

/**
 * 获取最后查询的城市
 * @returns {string|null} 城市名称
 */
export function getLastCity() {
  return localStorage.getItem(STORAGE_KEYS.LAST_CITY);
}

5. JavaScript模块 (js/ui.js)

// ==================== DOM元素 ====================
const elements = {
  cityInput: document.getElementById('cityInput'),
  searchBtn: document.getElementById('searchBtn'),
  loading: document.getElementById('loading'),
  error: document.getElementById('error'),
  weatherCard: document.getElementById('weatherCard'),
  cityName: document.getElementById('cityName'),
  weatherDate: document.getElementById('weatherDate'),
  weatherIcon: document.getElementById('weatherIcon'),
  temperature: document.getElementById('temperature'),
  description: document.getElementById('description'),
  humidity: document.getElementById('humidity'),
  windSpeed: document.getElementById('windSpeed'),
  feelsLike: document.getElementById('feelsLike'),
  pressure: document.getElementById('pressure'),
  visibility: document.getElementById('visibility'),
  clouds: document.getElementById('clouds'),
  saveCityBtn: document.getElementById('saveCityBtn'),
  refreshBtn: document.getElementById('refreshBtn'),
  savedCities: document.getElementById('savedCities'),
  searchHistory: document.getElementById('searchHistory'),
  clearHistoryBtn: document.getElementById('clearHistoryBtn')
};

// ==================== 天气图标映射 ====================
const weatherIcons = {
  '01d': '☀️', '01n': '🌙',
  '02d': '⛅', '02n': '☁️',
  '03d': '☁️', '03n': '☁️',
  '04d': '☁️', '04n': '☁️',
  '09d': '🌧️', '09n': '🌧️',
  '10d': '🌦️', '10n': '🌧️',
  '11d': '⛈️', '11n': '⛈️',
  '13d': '❄️', '13n': '❄️',
  '50d': '🌫️', '50n': '🌫️'
};

// ==================== UI操作函数 ====================

/**
 * 显示加载状态
 */
export function showLoading() {
  elements.loading.classList.remove('hidden');
  elements.error.classList.add('hidden');
  elements.weatherCard.classList.add('hidden');
}

/**
 * 隐藏加载状态
 */
export function hideLoading() {
  elements.loading.classList.add('hidden');
}

/**
 * 显示错误信息
 * @param {string} message - 错误信息
 */
export function showError(message) {
  elements.error.textContent = message;
  elements.error.classList.remove('hidden');
  elements.weatherCard.classList.add('hidden');
}

/**
 * 显示天气信息
 * @param {Object} data - 天气数据
 */
export function showWeather(data) {
  const { main, weather, wind, visibility, clouds, name } = data;

  // 更新基本信息
  elements.cityName.textContent = name;
  elements.weatherDate.textContent = formatDate();

  // 更新天气图标
  const iconCode = weather[0].icon;
  elements.weatherIcon.textContent = weatherIcons[iconCode] || '🌡️';

  // 更新温度
  elements.temperature.textContent = `${Math.round(main.temp)}°C`;
  elements.description.textContent = weather[0].description;

  // 更新详细信息
  elements.humidity.textContent = `${main.humidity}%`;
  elements.windSpeed.textContent = `${wind.speed} m/s`;
  elements.feelsLike.textContent = `${Math.round(main.feels_like)}°C`;
  elements.pressure.textContent = `${main.pressure} hPa`;
  elements.visibility.textContent = `${(visibility / 1000).toFixed(1)} km`;
  elements.clouds.textContent = `${clouds.all}%`;

  // 显示天气卡片
  elements.error.classList.add('hidden');
  elements.weatherCard.classList.remove('hidden');
}

/**
 * 格式化日期
 * @returns {string} 格式化的日期字符串
 */
function formatDate() {
  const now = new Date();
  const options = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    weekday: 'long',
    hour: '2-digit',
    minute: '2-digit'
  };
  return now.toLocaleDateString('zh-CN', options);
}

/**
 * 更新已保存城市列表
 * @param {string[]} cities - 城市列表
 * @param {Function} onCityClick - 城市点击回调
 * @param {Function} onDeleteClick - 删除按钮点击回调
 */
export function updateSavedCities(cities, onCityClick, onDeleteClick) {
  if (cities.length === 0) {
    elements.savedCities.innerHTML = '<p class="empty-message">暂无保存的城市</p>';
    return;
  }

  elements.savedCities.innerHTML = cities.map(city => `
    <div class="city-tag" data-city="${city}">
      <span>${city}</span>
      <button class="delete-btn" data-city="${city}">×</button>
    </div>
  `).join('');

  // 添加事件监听
  elements.savedCities.querySelectorAll('.city-tag').forEach(tag => {
    tag.addEventListener('click', (e) => {
      if (!e.target.classList.contains('delete-btn')) {
        onCityClick(tag.dataset.city);
      }
    });
  });

  elements.savedCities.querySelectorAll('.delete-btn').forEach(btn => {
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      onDeleteClick(btn.dataset.city);
    });
  });
}

/**
 * 更新搜索历史
 * @param {string[]} history - 搜索历史
 * @param {Function} onHistoryClick - 历史项点击回调
 */
export function updateSearchHistory(history, onHistoryClick) {
  if (history.length === 0) {
    elements.searchHistory.innerHTML = '<p class="empty-message">暂无搜索历史</p>';
    return;
  }

  elements.searchHistory.innerHTML = history.map(city => `
    <div class="history-tag" data-city="${city}">
      ${city}
    </div>
  `).join('');

  // 添加事件监听
  elements.searchHistory.querySelectorAll('.history-tag').forEach(tag => {
    tag.addEventListener('click', () => {
      onHistoryClick(tag.dataset.city);
    });
  });
}

/**
 * 导出DOM元素
 */
export { elements };

6. JavaScript主应用 (js/app.js)

import { getWeather } from './api.js';
import * as storage from './storage.js';
import * as ui from './ui.js';
import { elements } from './ui.js';

// ==================== 防抖函数 ====================
function debounce(func, delay) {
  let timeoutId;
  return function(...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}

// ==================== 查询天气 ====================
async function searchWeather(city) {
  if (!city || city.trim() === '') {
    ui.showError('请输入城市名称');
    return;
  }

  ui.showLoading();

  try {
    const data = await getWeather(city);
    ui.showWeather(data);
    storage.addToHistory(city);
    storage.saveLastCity(city);
    updateUI();
  } catch (error) {
    ui.showError(error.message);
  } finally {
    ui.hideLoading();
  }
}

// ==================== 保存城市 ====================
function saveCurrentCity() {
  const city = elements.cityName.textContent;
  if (city && city !== '--') {
    storage.addCity(city);
    updateUI();
  }
}

// ==================== 删除城市 ====================
function deleteCity(city) {
  storage.removeCity(city);
  updateUI();
}

// ==================== 清空历史 ====================
function clearHistory() {
  if (confirm('确定要清空搜索历史吗?')) {
    storage.clearHistory();
    updateUI();
  }
}

// ==================== 更新UI ====================
function updateUI() {
  // 更新已保存城市
  const savedCities = storage.getSavedCities();
  ui.updateSavedCities(savedCities, searchWeather, deleteCity);

  // 更新搜索历史
  const history = storage.getSearchHistory();
  ui.updateSearchHistory(history, searchWeather);
}

// ==================== 事件监听 ====================
elements.searchBtn.addEventListener('click', () => {
  const city = elements.cityInput.value.trim();
  searchWeather(city);
});

// 回车键搜索
elements.cityInput.addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    searchWeather(elements.cityInput.value.trim());
  }
});

// 保存城市
elements.saveCityBtn.addEventListener('click', saveCurrentCity);

// 刷新天气
elements.refreshBtn.addEventListener('click', () => {
  const city = elements.cityName.textContent;
  if (city && city !== '--') {
    searchWeather(city);
  }
});

// 清空历史
elements.clearHistoryBtn.addEventListener('click', clearHistory);

// ==================== 初始化 ====================
function init() {
  updateUI();

  // 恢复上次查询的城市
  const lastCity = storage.getLastCity();
  if (lastCity) {
    elements.cityInput.value = lastCity;
    searchWeather(lastCity);
  }

  // 聚焦输入框
  elements.cityInput.focus();
}

// 启动应用
init();

⚠️ 重要注意事项

1. API密钥安全

⚠️ 不要在前端代码中硬编码API密钥!

这个项目为了演示方便,直接在代码中使用了API密钥。但在生产环境中,你应该:

  1. 使用代理服务器
// 前端
const response = await fetch('/api/weather?city=Beijing');

// 后端(Node.js)
app.get('/api/weather', async (req, res) => {
  const city = req.query.city;
  const apiKey = process.env.OPENWEATHERMAP_API_KEY; // 从环境变量读取
  const url = `${API_BASE}?q=${city}&appid=${apiKey}`;
  const response = await fetch(url);
  const data = await response.json();
  res.json(data);
});

2. 错误处理

关键错误场景

  • 网络请求失败
  • API密钥无效
  • 城市名称错误
  • 服务器超时

处理策略

try {
  const data = await getWeather(city);
  ui.showWeather(data);
} catch (error) {
  if (error.message.includes('Failed to fetch')) {
    ui.showError('网络连接失败,请检查网络');
  } else if (error.message.includes('404')) {
    ui.showError('未找到该城市');
  } else {
    ui.showError('查询失败,请稍后重试');
  }
}

3. 性能优化

防抖:避免频繁的API请求

const debouncedSearch = debounce(searchWeather, 500);
elements.cityInput.addEventListener('input', (e) => {
  debouncedSearch(e.target.value);
});

缓存:减少重复请求

const cache = new Map();

async function getCachedWeather(city) {
  if (cache.has(city)) {
    const cached = cache.get(city);
    if (Date.now() - cached.timestamp < 600000) { // 10分钟缓存
      return cached.data;
    }
  }

  const data = await getWeather(city);
  cache.set(city, { data, timestamp: Date.now() });
  return data;
}

✍️ 练习任务

基础练习

  1. 运行项目

    • 替换API密钥
    • 在本地服务器运行项目
    • 测试所有功能
  2. 自定义样式

    • 修改颜色主题
    • 调整布局和间距

进阶练习

  1. 添加新功能

    • 5天天气预报
    • 城市地图定位
    • 天气图表
  2. 性能优化

    • 实现请求缓存
    • 添加加载动画优化

实战练习

  1. 部署到生产
    • 使用Netlify/Vercel部署
    • 配置环境变量
    • 设置CDN

🎓 项目总结

恭喜你完成了编程入门教程的终极项目

你已经掌握

基础知识

  • ✅ JavaScript核心语法
  • ✅ DOM操作和事件处理
  • ✅ CSS布局和动画
  • ✅ 异步编程

实战技能

  • ✅ API集成
  • ✅ 本地存储
  • ✅ 错误处理
  • ✅ 响应式设计
  • ✅ 模块化开发

下一步建议

  1. 深入学习

    • TypeScript(类型系统)
    • React/Vue(前端框架)
    • Node.js(后端开发)
  2. 项目实战

    • 构建自己的作品集
    • 参与开源项目
    • 开发实用工具
  3. 持续学习

    • 关注技术趋势
    • 阅读优秀代码
    • 加入开发者社区

🎉 恭喜完成30天编程入门之旅!

继续编程,创造精彩! 🚀