乌兰察布市实时气象数据可视化大屏

《乌兰察布市实时气象数据可视化大屏》------ 教师视角完整教程

面向对象 :本科 / 高职《大数据可视化》《前端工程化》课程教师与学生

预计课时 :12 学时(理论 4 + 实操 8)

技术栈 :React 19 + TypeScript 6 + Vite 8 + ECharts 6 + TailwindCSS 4 + Express 5

学完能做什么:从 0 搭建一个「真实数据接入 + 服务端定时推送 + 大屏可视化」的完整全栈项目,并理解其分层与数据流。


## 目录

  • [第 0 章 课程导读与项目全景](#第 0 章 课程导读与项目全景)
  • [第 1 章 环境与工程脚手架](#第 1 章 环境与工程脚手架)
  • [第 2 章 项目目录与配置](#第 2 章 项目目录与配置)
  • [第 3 章 后端:Express 5 + 定时任务 + Open-Meteo 数据接入](#第 3 章 后端:Express 5 + 定时任务 + Open-Meteo 数据接入)
  • [第 4 章 后端:SSE 实时推送总线](#第 4 章 后端:SSE 实时推送总线)
  • [第 5 章 前端:React 19 + Vite 8 + TailwindCSS 4 骨架](#第 5 章 前端:React 19 + Vite 8 + TailwindCSS 4 骨架)
  • [第 6 章 前端:主题样式与深色科技设计系统](#第 6 章 前端:主题样式与深色科技设计系统)
  • [第 7 章 前端:React 19 Context + SSE 数据订阅](#第 7 章 前端:React 19 Context + SSE 数据订阅)
  • [第 8 章 前端:ECharts 6 可视化组件开发](#第 8 章 前端:ECharts 6 可视化组件开发)
  • [第 9 章 前端:页面拼装与三栏布局](#第 9 章 前端:页面拼装与三栏布局)
  • [第 10 章 启动、调试与常见问题](#第 10 章 启动、调试与常见问题)
  • [附录 A:完整文件清单](#附录 A:完整文件清单)

第 0 章 课程导读与项目全景

0.1 我们要做什么

为乌兰察布市(坐标 N 40.9945° / E 113.1322° )做一个 实时气象数据可视化大屏,运行后效果类似「指挥中心监控屏」:

  • 顶部居中标题 + 实时时钟,左右两侧状态指示;
  • 左栏:实时指标卡片(气温 / 湿度 / 风速 / 气压)+ 风向玫瑰图;
  • 中栏:温度趋势线(上)+ 气象综合雷达图(下);
  • 右栏:空气质量监测面板(上)+ 温湿度双轴图(下);
  • 底部通栏:气压多指标趋势(左)+ 温湿度散点图(中)+ 逐小时降水量(右);
  • 底部信息栏:地理位置、数据来源、各数据源更新时间。

0.2 数据从哪里来

完全使用 Open-Meteo 的两个免费、无需 Key 的开放 REST 接口:

用途 地址
气象(实时 + 24h 预报) https://api.open-meteo.com/v1/forecast
空气质量 https://air-quality-api.open-meteo.com/v1/air-quality

Open-Meteo 数据聚合自 ECMWF、NOAA、中国气象局等机构,可直接用于教学。

0.3 整体架构

0.4 关键技术点(一句话版)

  • Express 5 + node-cron:后端定时拉数据(Express 5 支持 async handler)。
  • SSE (Server-Sent Events):服务端主动推送,比轮询省延迟。
  • React 19 + Vite 8 :组件化前端 + 极速 HMR + use() hook。
  • ECharts 6 + 自定义 ChartCanvas:数据可视化(不依赖 echarts-for-react)。
  • TailwindCSS 4 + CSS 变量:深色科技风设计系统。
  • React 19 Context + use():统一数据管理(替代多个独立 hooks)。

第 1 章 环境与工程脚手架

1.1 环境要求

工具 最低版本 验证命令
Node.js ≥ 20 node -v
npm ≥ 10 npm -v
浏览器 Chrome / Edge / Firefox 最新版 ---

1.2 创建项目目录

bash 复制代码
mkdir wulanchabu-weather-dashboard
cd wulanchabu-weather-dashboard

1.3 用 Vite 初始化前端(React + TypeScript)

教学提示:执行命令时全部用 -- --template react-ts,避免交互卡住。

bash 复制代码
npm create vite@latest . -- --template react-ts

按提示确认覆盖当前目录即可。生成结构里我们只需要保留 index.htmlsrc/,其余脚手架自带文件可保留作为参考。

1.4 安装后端依赖

bash 复制代码
npm install express cors dotenv node-cron axios
npm install -D nodemon tsx concurrently @types/node @types/express @types/cors

1.5 安装前端可视化与样式依赖

bash 复制代码
npm install echarts axios lucide-react clsx tailwind-merge
npm install -D tailwindcss@^4 @tailwindcss/vite vite-tsconfig-paths

课堂提问:为什么不用 echarts-for-react

答:echarts-for-react 与 ECharts 6 不兼容(ECharts 6 重构了模块导出)。我们手写一个更轻量的 ChartCanvas 组件,还能学习 ResizeObserver 和 ECharts 6 的 tree-shaking 机制。

1.6 最终 package.json 关键片段

json 复制代码
{
  "name": "wulanchabu-weather-dashboard",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "client:dev": "vite --port 5173 --strictPort --host 127.0.0.1",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "check": "tsc --noEmit",
    "server:dev": "nodemon",
    "dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
  },
  "dependencies": {
    "@tailwindcss/vite": "^4.3.2",
    "axios": "^1.18.1",
    "clsx": "^2.1.1",
    "cors": "^2.8.6",
    "dotenv": "^17.4.2",
    "echarts": "^6.1.0",
    "express": "^5.2.1",
    "lucide-react": "^1.23.0",
    "node-cron": "^4.5.0",
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "tailwind-merge": "^3.6.0"
  },
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "@types/node": "^26.1.0",
    "@types/node-cron": "^3.0.11",
    "@types/react": "^19.2.17",
    "@types/react-dom": "^19.2.3",
    "@vercel/node": "^5.8.22",
    "@vitejs/plugin-react": "^6.0.3",
    "concurrently": "^10.0.3",
    "eslint": "^10.6.0",
    "eslint-plugin-react-hooks": "^7.1.1",
    "eslint-plugin-react-refresh": "^0.5.3",
    "nodemon": "^3.1.14",
    "tsx": "^4.22.5",
    "typescript": "~6.0.3",
    "typescript-eslint": "^8.62.1",
    "vite": "^8.1.3"
  }
}

课堂提问:为什么用 concurrently

答:让前端 Vite(5173)和后端 nodemon(3001)在同一条命令里并行启动,避免开两个终端。

1.7 安装并验证

bash 复制代码
npm install
npm run dev

如果浏览器弹出 5173 端口和 Node 3001 端口均就绪,说明脚手架完成。


第 2 章 项目目录与配置

2.1 目录约定

复制代码
.
├── api/                       # 后端(Node + Express 5 + TS)
│   ├── server.ts              # 本地启动入口
│   ├── app.ts                 # Express 应用工厂
│   ├── index.ts               # Vercel Serverless 入口
│   ├── routes/
│   │   ├── weather.ts
│   │   ├── airQuality.ts
│   │   └── auth.ts            # 教学扩展用
│   ├── services/
│   │   ├── weatherService.ts
│   │   └── airQualityService.ts
│   └── sse/
│       └── broadcaster.ts
├── src/                       # 前端(React 19 + TS)
│   ├── main.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── components/
│   │   ├── Header/
│   │   ├── Footer/
│   │   ├── WeatherCards/
│   │   ├── AirQualityPanel/   # 新增:空气质量面板
│   │   ├── EChartsWrapper/    # 新增:自定义 ECharts 6 包装
│   │   │   └── ChartCanvas.tsx
│   │   ├── TemperatureChart/
│   │   ├── PrecipitationChart/
│   │   ├── TempHumidCombo/
│   │   ├── PressureChart/
│   │   ├── ScatterChart/
│   │   ├── RadarChart/
│   │   └── WindRoseChart/
│   ├── context/               # 新增:统一数据管理
│   │   └── DataContext.tsx
│   ├── lib/
│   │   ├── useSse.ts          # 保留:通用 SSE hook(备用)
│   │   ├── chartTheme.ts      # 新增:ECharts 6 主题
│   │   └── utils.ts
│   └── pages/
│       └── Home.tsx
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── eslint.config.js           # ESLint 9 flat config
└── nodemon.json

教学说明:相比旧教程,我们移除了 hooks/ 目录(改用 Context),新增了 context/EChartsWrapper/

2.2 tsconfig.json

要点:

  • module: ESNextmoduleResolution: bundler:让 TS 与 Vite 兼容;
  • paths: { "@/*": ["./src/*"] }:让前端用 @/... 简化导入;
  • include: ["src", "api"]:后端 TS 与前端共用同一 tsconfig。
json 复制代码
{
  "compilerOptions": {
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": "./",
    "paths": { "@/*": ["./src/*"] },
    "types": ["node", "express"]
  },
  "include": ["src", "api"]
}

2.3 nodemon.json

json 复制代码
{
  "watch": ["api"],
  "ext": "ts,mts,js,json",
  "ignore": ["api/dist/*"],
  "exec": "tsx api/server.ts",
  "env": { "NODE_ENV": "development" },
  "delay": 1000
}

课堂提问:为什么是 tsx 而不是 ts-node

答:tsx 基于 esbuild,启动速度比 ts-node 快一个数量级,适合教学演示。

2.4 vite.config.ts

/api 反向代理到后端 3001,前端就只需要写 /api/...

ts 复制代码
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import tsconfigPaths from "vite-tsconfig-paths"

export default defineConfig({
  plugins: [react(), tailwindcss(), tsconfigPaths()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3001',
        changeOrigin: true,
        secure: false,
      }
    }
  }
})

教学说明:TailwindCSS 4 使用 @tailwindcss/vite 插件,不再需要 tailwind.config.jspostcss.config.js

2.5 eslint.config.js(ESLint 9 flat config)

js 复制代码
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  { ignores: ['dist', 'node_modules'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2022,
      parser: tseslint.parser,
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
      '@typescript-eslint/no-explicit-any': 'warn',
    },
  },
)

第 3 章 后端:Express 5 + 定时任务 + Open-Meteo 数据接入

3.1 入口文件 api/server.ts(本地启动用)

ts 复制代码
/**
 * local server entry file, for local development
 */
import app from './app.js';

const PORT = process.env.PORT || 3001;

const server = app.listen(PORT, () => {
  console.log(`Server ready on port ${PORT}`);
});

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
  server.close(() => process.exit(0));
});

export default app;

3.2 应用工厂 api/app.ts

这是整个后端的核心:装配中间件、注册路由、注册 SSE、配置定时任务。

ts 复制代码
/**
 * 乌兰察布市实时气象数据可视化大屏 - API服务器
 * 数据源: Open-Meteo 官方开放REST接口
 * 定时任务: 每3分钟轮询一次数据
 */

import express, { type Request, type Response, type NextFunction } from 'express'
import cors from 'cors'
import path from 'path'
import dotenv from 'dotenv'
import { fileURLToPath } from 'url'
import cron from 'node-cron'

import createWeatherRouter from './routes/weather.js'
import createAirQualityRouter from './routes/airQuality.js'

import { refreshWeatherData, getWeatherData } from './services/weatherService.js'
import { refreshAirQualityData, getAirQualityData } from './services/airQualityService.js'

import { addClient, broadcast } from './sse/broadcaster.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

dotenv.config()

const app: express.Application = express()

app.use(cors())
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' }))

// REST 路由
app.use('/api/weather', createWeatherRouter())
app.use('/api/air-quality', createAirQualityRouter())

// 健康检查
app.use('/api/health', (_req, res) => {
  res.status(200).json({ success: true, message: 'ok', timestamp: new Date().toISOString() })
})

// SSE 实时流
app.get('/api/stream', (req, res) => {
  addClient(res)
  // 连接后立刻把当前缓存推一份给前端
  Promise.allSettled([getWeatherData(), getAirQualityData()]).then(([w, a]) => {
    if (w.status === 'fulfilled') broadcast('weather', w.value)
    if (a.status === 'fulfilled') broadcast('air-quality', a.value)
  })
})

// 全局错误处理(Express 5 支持 async handler)
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => {
  console.error('Server error:', error)
  res.status(500).json({ success: false, error: 'Server internal error' })
})

// 404
app.use((_req, res) => {
  res.status(404).json({ success: false, error: 'API not found' })
})

/** 启动时立即抓一次 */
async function initializeData() {
  console.log('正在初始化数据...')
  await Promise.all([refreshWeatherData(), refreshAirQualityData()])
  console.log('数据初始化完成')
}

/** 每 3 分钟刷新一次 + 推送给所有 SSE 客户端 */
function setupScheduledTasks() {
  cron.schedule('*/3 * * * *', async () => {
    await refreshWeatherData()
    await refreshAirQualityData()
    const [w, a] = await Promise.allSettled([getWeatherData(), getAirQualityData()])
    if (w.status === 'fulfilled') broadcast('weather', w.value)
    if (a.status === 'fulfilled') broadcast('air-quality', a.value)
  })
  console.log('定时任务已配置: 每3分钟刷新数据')
}

initializeData()
setupScheduledTasks()

export default app

课堂提问:Express 5 与 Express 4 的主要区别?

答:Express 5 原生支持 async handler(自动捕获 Promise rejection),路由参数类型更安全,性能更好。

3.3 数据服务:气象 api/services/weatherService.ts

ts 复制代码
import axios from 'axios';

const LATITUDE = 40.9945;
const LONGITUDE = 113.1322;
const TIMEZONE = 'Asia/Shanghai';
const WEATHER_API_URL = 'https://api.open-meteo.com/v1/forecast';

let weatherCache: WeatherData | null = null;

export interface WeatherData {
  location: { name: string; latitude: number; longitude: number; timezone: string };
  current: {
    temperature: number; humidity: number; windSpeed: number;
    windDirection: number; pressure: number; precipitation: number; time: string;
  };
  hourly: {
    time: string[]; temperature: number[]; precipitation: number[];
    humidity: number[]; pressure: number[]; windSpeed: number[]; windDirection: number[];
  };
  updatedAt: string;
}

/** 从 Open-Meteo 获取 + 清洗 */
async function fetchWeatherFromAPI(): Promise<WeatherData> {
  const url =
    `${WEATHER_API_URL}?latitude=${LATITUDE}&longitude=${LONGITUDE}` +
    `&current=temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m,surface_pressure,precipitation` +
    `&hourly=temperature_2m,precipitation,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m` +
    `&forecast_hours=24&timezone=${encodeURIComponent(TIMEZONE)}`;

  const { data } = await axios.get(url);
  return {
    location: { name: '乌兰察布市', latitude: LATITUDE, longitude: LONGITUDE, timezone: TIMEZONE },
    current: {
      temperature: Math.round(data.current.temperature_2m * 10) / 10,
      humidity: Math.round(data.current.relative_humidity_2m),
      windSpeed: Math.round(data.current.wind_speed_10m * 10) / 10,
      windDirection: data.current.wind_direction_10m,
      pressure: Math.round(data.current.surface_pressure),
      precipitation: Math.round(data.current.precipitation * 100) / 100,
      time: data.current.time,
    },
    hourly: {
      time: data.hourly.time,
      temperature: data.hourly.temperature_2m.map((v: number) => Math.round(v * 10) / 10),
      precipitation: data.hourly.precipitation.map((v: number) => Math.round(v * 100) / 100),
      humidity: (data.hourly.relative_humidity_2m as number[]).map((v: number) => Math.round(v)),
      pressure: (data.hourly.surface_pressure as number[]).map((v: number) => Math.round(v)),
      windSpeed: (data.hourly.wind_speed_10m as number[]).map((v: number) => Math.round(v * 10) / 10),
      windDirection: (data.hourly.wind_direction_10m as number[]).map((v: number) => Math.round(v)),
    },
    updatedAt: new Date().toISOString(),
  };
}

export async function getWeatherData(): Promise<WeatherData> {
  if (!weatherCache) weatherCache = await fetchWeatherFromAPI();
  return weatherCache;
}

export async function refreshWeatherData(): Promise<void> {
  try {
    console.log(`[${new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE })}] 正在刷新气象数据...`);
    weatherCache = await fetchWeatherFromAPI();
    console.log(`[${new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE })}] 气象数据刷新成功`);
  } catch (error) {
    console.error('气象数据刷新失败:', error instanceof Error ? error.message : error);
    // 失败时保留旧缓存,避免页面空白
  }
}

3.4 数据服务:空气质量 api/services/airQualityService.ts

ts 复制代码
import axios from 'axios';

const LATITUDE = 40.9945;
const LONGITUDE = 113.1322;
const TIMEZONE = 'Asia/Shanghai';
const AIR_QUALITY_API_URL = 'https://air-quality-api.open-meteo.com/v1/air-quality';

let airQualityCache: AirQualityData | null = null;

export interface AirQualityData {
  location: { latitude: number; longitude: number };
  current: { aqi: number; pm25: number; pm10: number; no2: number; o3: number };
  updatedAt: string;
}

function getFallbackData(): AirQualityData {
  return {
    location: { latitude: LATITUDE, longitude: LONGITUDE },
    current: { aqi: 0, pm25: 0, pm10: 0, no2: 0, o3: 0 },
    updatedAt: new Date().toISOString(),
  };
}

async function fetchAirQualityFromAPI(): Promise<AirQualityData> {
  try {
    const url =
      `${AIR_QUALITY_API_URL}?latitude=${LATITUDE}&longitude=${LONGITUDE}` +
      `&current=pm2_5,pm10,nitrogen_dioxide,ozone,us_aqi&timezone=${encodeURIComponent(TIMEZONE)}`;
    const { data } = await axios.get(url, { timeout: 10000 });
    if (data.error) throw new Error(`API错误: ${data.reason || '未知错误'}`);
    return {
      location: { latitude: LATITUDE, longitude: LONGITUDE },
      current: {
        aqi: Math.round(data.current.us_aqi ?? 0),
        pm25: Math.round((data.current.pm2_5 || 0) * 10) / 10,
        pm10: Math.round((data.current.pm10 || 0) * 10) / 10,
        no2: Math.round((data.current.nitrogen_dioxide || 0) * 10) / 10,
        o3: Math.round(data.current.ozone || 0),
      },
      updatedAt: new Date().toISOString(),
    };
  } catch (error) {
    console.warn('Open-Meteo 空气质量API调用失败,使用降级数据:',
      error instanceof Error ? error.message : error);
    return getFallbackData();
  }
}

export async function getAirQualityData(): Promise<AirQualityData> {
  if (!airQualityCache) airQualityCache = await fetchAirQualityFromAPI();
  return airQualityCache;
}

export async function refreshAirQualityData(): Promise<void> {
  try {
    console.log(`[${new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE })}] 正在刷新空气质量数据...`);
    airQualityCache = await fetchAirQualityFromAPI();
    if (airQualityCache.current.aqi === 0 && airQualityCache.current.pm25 === 0) {
      console.log(`[${new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE })}] 空气质量数据: 当前为降级模式(API可能暂时不可用)`);
    } else {
      console.log(`[${new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE })}] 空气质量数据刷新成功`);
    }
  } catch (error) {
    console.error('空气质量数据刷新失败:', error instanceof Error ? error.message : error);
  }
}

3.5 路由 api/routes/weather.ts

ts 复制代码
import Router, { type Request, type Response, type Router as RouterType } from 'express';
import { getWeatherData } from '../services/weatherService.js';

function createWeatherRouter(): RouterType {
  const router = Router();
  router.get('/', async (_req: Request, res: Response): Promise<void> => {
    try {
      const data = await getWeatherData();
      res.json({ success: true, data });
    } catch (error) {
      console.error('获取气象数据失败:', error);
      res.status(500).json({ success: false, error: '获取气象数据失败,请稍后重试' });
    }
  });
  return router;
}

export default createWeatherRouter;

3.6 路由 api/routes/airQuality.ts

ts 复制代码
import Router, { type Request, type Response, type Router as RouterType } from 'express';
import { getAirQualityData } from '../services/airQualityService.js';

function createAirQualityRouter(): RouterType {
  const router = Router();
  router.get('/', async (_req: Request, res: Response): Promise<void> => {
    try {
      const data = await getAirQualityData();
      res.json({ success: true, data });
    } catch (error) {
      console.error('获取空气质量数据失败:', error);
      res.status(500).json({ success: false, error: '获取空气质量数据失败,请稍后重试' });
    }
  });
  return router;
}

export default createAirQualityRouter;

3.7 Vercel 部署入口(可选)

ts 复制代码
// api/index.ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';

export default function handler(req: VercelRequest, res: VercelResponse) {
  return app(req, res);
}

3.8 验证后端

bash 复制代码
curl http://localhost:3001/api/health
curl http://localhost:3001/api/weather | head -c 400
curl http://localhost:3001/api/air-quality | head -c 200

第 4 章 后端:SSE 实时推送总线

4.1 为什么用 SSE 而不是 WebSocket

维度 SSE WebSocket
协议 HTTP 独立协议
方向 单向(服务端 → 浏览器) 双向
浏览器内置 EventSource 需手写协议
重连 浏览器自动 需自己实现
适用 实时数据展示 聊天 / 协同编辑

本项目服务端主动推、客户端只收,SSE 刚好够用。

4.2 api/sse/broadcaster.ts

ts 复制代码
import type { Response } from 'express';

const clients = new Set<Response>();

/** 设置 SSE 响应头并保持连接 */
export function addClient(res: Response): void {
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders?.();

  // 立刻发一行注释触发浏览器 onopen
  res.write(`: connected ${new Date().toISOString()}\n\n`);

  // 25s 心跳,防止中间代理切断
  const heartbeat = setInterval(() => {
    res.write(`: ping ${Date.now()}\n\n`);
  }, 25_000);

  const cleanup = () => {
    clearInterval(heartbeat);
    clients.delete(res);
    try { res.end(); } catch { /* ignore */ }
  };

  res.on('close', cleanup);
  res.on('error', cleanup);
  clients.add(res);
}

/** 广播事件 */
export function broadcast(event: string, payload: unknown): void {
  const data = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
  for (const client of clients) {
    try { client.write(data); } catch { clients.delete(client); }
  }
}

export function clientCount(): number { return clients.size; }

4.3 用 curl 测试

bash 复制代码
curl -N -H "Accept: text/event-stream" http://localhost:3001/api/stream

你应该看到:

复制代码
: connected 2026-...
event: weather
data: {"location":...,"current":{...},"hourly":{...}}

event: air-quality
data: {"location":...,"current":{...}}

课堂提问:flushHeaders() 有什么用?

答:立刻把响应头冲刷给客户端,让浏览器尽早建立 SSE 连接;如果不调用,Node 会缓冲头部直到第一次 res.write()


第 5 章 前端:React 19 + Vite 8 + TailwindCSS 4 骨架

5.1 index.html

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>乌兰察布市实时气象监测大屏</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

5.2 src/main.tsx

tsx 复制代码
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

5.3 src/lib/utils.ts:className 合并

ts 复制代码
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

第 6 章 前端:主题样式与深色科技设计系统

6.1 设计目标

  • 深空科技风:背景 #060a14,品牌色青色 #00e0ff
  • 深色卡片:渐变背景 + 科技感边角装饰 + 悬浮发光;
  • 数字字体:Inter(标题)+ JetBrains Mono(数据);
  • 网格点背景营造纵深感。

6.2 完整 src/index.css

教学说明:TailwindCSS 4 使用 @import "tailwindcss" 语法,不再需要 @tailwind 指令。

css 复制代码
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
@import "tailwindcss";

:root {
  /* ====== 深色科技主题 ====== */

  /* 背景层 (深空蓝) */
  --bg-page: #060a14;
  --bg-card: #0c1426;
  --bg-card-2: #131c33;
  --bg-subtle: #0a1122;
  --bg-hover: #1a2540;

  /* 边框 */
  --border-light: #1a2540;
  --border-medium: #2a3a60;
  --border-strong: #3d4f7c;

  /* 文字 */
  --text-primary: #e8eef8;
  --text-secondary: #a8b3cf;
  --text-muted: #6b7a9a;
  --text-faint: #3d4b6b;

  /* 品牌色 (霓虹科技) */
  --brand-1: #00e0ff;   /* 主青 */
  --brand-2: #7d4dff;   /* 电紫 */
  --brand-3: #ff6b4a;   /* 暖橘红 */
  --brand-4: #00ffa3;   /* 霓虹绿 */
  --brand-5: #ffb347;   /* 琥珀 */

  /* 数据分类色 (语义保持) */
  --color-temp:    #ff6b4a;
  --color-humid:   #00b4ff;
  --color-wind:    #00ffa3;
  --color-press:   #b87dff;
  --color-rain:    #5cc4ff;
  --color-cloud:   #7a8aa8;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html, body, #root {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

body {
  font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: radial-gradient(ellipse at top, #0c1830 0%, var(--bg-page) 55%);
  color: var(--text-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}

.font-display { font-family: 'Inter', sans-serif; font-weight: 600; letter-spacing: -0.02em; }
.font-data { font-family: 'JetBrains Mono', monospace; font-feature-settings: 'tnum'; }

/* ====== 卡片 (深色 + 科技感边角 + 悬浮发光) ====== */
.card {
  position: relative;
  background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-card-2) 100%);
  border: 1px solid var(--border-light);
  border-radius: 10px;
  transition: border-color .2s ease, box-shadow .25s ease, transform .2s ease;
  overflow: hidden;
}

/* 四角科技感括号装饰 (top-left + bottom-right) */
.card::before,
.card::after {
  content: '';
  position: absolute;
  width: 10px;
  height: 10px;
  pointer-events: none;
  transition: all .2s ease;
  z-index: 2;
}
.card::before {
  top: 0; left: 0;
  border-top: 1.5px solid var(--brand-1);
  border-left: 1.5px solid var(--brand-1);
}
.card::after {
  bottom: 0; right: 0;
  border-bottom: 1.5px solid var(--brand-1);
  border-right: 1.5px solid var(--brand-1);
}

.card:hover {
  border-color: var(--brand-1);
  box-shadow: 0 0 20px -4px rgba(0, 224, 255, 0.25),
              inset 0 0 24px -8px rgba(0, 224, 255, 0.08);
  transform: translateY(-1px);
}
.card:hover::before,
.card:hover::after {
  filter: drop-shadow(0 0 4px rgba(0, 224, 255, 0.6));
}

/* 指标卡 (KPI) */
.kpi {
  position: relative;
  background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-card-2) 100%);
  border: 1px solid var(--border-light);
  border-radius: 10px;
  padding: 14px 16px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  transition: all .25s ease;
  overflow: hidden;
}
.kpi::before {
  content: '';
  position: absolute;
  top: 0; left: 0;
  width: 3px; height: 100%;
  background: linear-gradient(180deg, var(--accent, var(--brand-1)) 0%, transparent 100%);
}
.kpi:hover {
  border-color: var(--accent, var(--brand-1));
  box-shadow: 0 0 18px -6px var(--accent, var(--brand-1));
  transform: translateY(-2px);
}
.kpi-label {
  font-size: 11px;
  color: var(--text-muted);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.kpi-value {
  font-family: 'JetBrains Mono', monospace;
  font-size: 24px;
  font-weight: 600;
  color: var(--text-primary);
  line-height: 1.1;
  letter-spacing: -0.02em;
}
.kpi-unit {
  font-size: 12px;
  color: var(--text-secondary);
  margin-left: 2px;
  font-weight: 500;
}

/* 数字滚动动画 */
@keyframes countUp {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
.animate-count-up { animation: countUp .5s cubic-bezier(.34, 1.56, .64, 1) forwards; }

/* 网格点背景 */
.bg-dots {
  background-image: radial-gradient(circle, var(--border-light) 1px, transparent 1px);
  background-size: 22px 22px;
}

/* 渐变文本 */
.gradient-text {
  background: linear-gradient(135deg, var(--brand-1) 0%, var(--brand-2) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

/* 扫描线动画 (Footer 用) */
@keyframes scan-x {
  0% { transform: translateX(-100%); }
  100% { transform: translateX(300%); }
}
.scan-line {
  position: relative;
  overflow: hidden;
}
.scan-line::before {
  content: '';
  position: absolute;
  top: 0; left: 0;
  width: 30%;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--brand-1), transparent);
  animation: scan-x 4s linear infinite;
  pointer-events: none;
}

/* 头部渐变描边 */
.header-glow {
  position: relative;
}
.header-glow::after {
  content: '';
  position: absolute;
  bottom: 0; left: 0; right: 0;
  height: 1px;
  background: linear-gradient(90deg,
    transparent 0%,
    var(--brand-1) 30%,
    var(--brand-2) 70%,
    transparent 100%);
  box-shadow: 0 0 8px rgba(0, 224, 255, 0.5);
}

/* 数字发光效果 */
.text-glow {
  text-shadow: 0 0 8px currentColor;
}

课堂提问:TailwindCSS 4 与 3 的主要区别?

答:Tailwind 4 使用 CSS-first 配置(不再需要 tailwind.config.js),性能提升 10 倍,支持 @import "tailwindcss" 语法。


第 7 章 前端:React 19 Context + SSE 数据订阅

7.1 src/context/DataContext.tsx:统一数据管理

教学说明:相比旧教程的独立 hooks,我们使用 React 19 的 use() hook + Context 统一管理数据。

tsx 复制代码
/**
 * 全局数据 Context
 *
 * 负责:
 * 1. 维护气象 + 空气质量数据状态
 * 2. 订阅 SSE 实时推送
 * 3. 首屏 REST 兜底(避免空白)
 * 4. 提供手动刷新方法
 */

import {
  createContext,
  use,
  useState,
  useEffect,
  type ReactNode,
} from 'react'
import axios from 'axios'
import type { WeatherData } from '../../api/services/weatherService'
import type { AirQualityData } from '../../api/services/airQualityService'

const API_BASE =
  (import.meta.env.VITE_API_BASE as string | undefined) || 'http://localhost:3001'

/* ================================================================
 *  Context 类型定义
 * ================================================================ */

interface DataContextValue {
  weather: WeatherData | null
  airQuality: AirQualityData | null
  loading: boolean
  /** 刷新数据的异步函数 */
  refreshData: () => Promise<void>
}

/* ================================================================
 *  创建 Context(初始值由 use() 读取时决定)
 * ================================================================ */

const DataContext = createContext<DataContextValue | null>(null)

/**
 * 【React 19 - use hook】读取数据上下文
 *
 * use() 是 React 19 新增的 API,可替代 useContext:
 * - 可在条件语句和循环中调用(useContext 不行)
 * - 可直接读取 Promise(配合 Suspense)
 * - 读取 Context 时行为与 useContext 一致
 */
export function useData(): DataContextValue {
  const ctx = use(DataContext)
  if (!ctx) throw new Error('useData 必须在 DataProvider 内部使用')
  return ctx
}

/* ================================================================
 *  Provider 组件
 * ================================================================ */

interface DataProviderProps {
  children: ReactNode
}

export function DataProvider({ children }: DataProviderProps) {
  const [weather, setWeather] = useState<WeatherData | null>(null)
  const [airQuality, setAirQuality] = useState<AirQualityData | null>(null)
  const [loading, setLoading] = useState(true)

  /* ---------- SSE 订阅 ---------- */
  useEffect(() => {
    let es: EventSource | null = null
    let retryTimer: ReturnType<typeof setTimeout> | null = null
    let retryDelay = 1000
    let cancelled = false

    const connect = (): void => {
      if (cancelled) return
      es = new EventSource(`${API_BASE}/api/stream`)

      es.onopen = () => {
        retryDelay = 1000
      }

      es.addEventListener('weather', (ev: MessageEvent) => {
        try {
          setWeather(JSON.parse(ev.data))
          setLoading(false)
        } catch (err) {
          console.error('[SSE] 解析 weather 事件失败:', err)
        }
      })

      es.addEventListener('air-quality', (ev: MessageEvent) => {
        try {
          setAirQuality(JSON.parse(ev.data))
          setLoading(false)
        } catch (err) {
          console.error('[SSE] 解析 air-quality 事件失败:', err)
        }
      })

      es.onerror = () => {
        es?.close()
        es = null
        if (cancelled) return
        retryTimer = setTimeout(connect, retryDelay)
        retryDelay = Math.min(retryDelay * 2, 10_000)
      }
    }

    connect()

    const onVisibility = (): void => {
      if (document.visibilityState === 'visible') {
        es?.close()
        es = null
        if (retryTimer) clearTimeout(retryTimer)
        retryDelay = 1000
        connect()
      }
    }
    document.addEventListener('visibilitychange', onVisibility)

    return () => {
      cancelled = true
      if (retryTimer) clearTimeout(retryTimer)
      es?.close()
      document.removeEventListener('visibilitychange', onVisibility)
    }
  }, [])

  /* ---------- REST 兜底(首屏) ---------- */
  useEffect(() => {
    const fetchInitial = async (): Promise<void> => {
      try {
        const [w, a] = await Promise.allSettled([
          axios.get(`${API_BASE}/api/weather`),
          axios.get(`${API_BASE}/api/air-quality`),
        ])
        if (w.status === 'fulfilled' && w.value.data.success) setWeather(w.value.data.data)
        if (a.status === 'fulfilled' && a.value.data.success) setAirQuality(a.value.data.data)
        if (w.status === 'rejected') console.warn('首屏气象数据暂不可用,等待 SSE 推送')
        if (a.status === 'rejected') console.warn('首屏空气质量数据暂不可用,等待 SSE 推送')
      } catch (err) {
        console.warn('首屏数据兜底异常:', err)
      } finally {
        setLoading(false)
      }
    }
    fetchInitial()
  }, [])

  /* ================================================================
   *  刷新数据函数
   * ================================================================ */
  const refreshData = async (): Promise<void> => {
    try {
      setLoading(true)
      const [w, a] = await Promise.all([
        axios.get(`${API_BASE}/api/weather`),
        axios.get(`${API_BASE}/api/air-quality`),
      ])
      if (w.data.success) setWeather(w.data.data)
      if (a.data.success) setAirQuality(a.data.data)
    } catch (err) {
      console.error('刷新数据失败:', err)
    } finally {
      setLoading(false)
    }
  }

  const value: DataContextValue = {
    weather,
    airQuality,
    loading,
    refreshData,
  }

  return <DataContext value={value}>{children}</DataContext>
}

课堂提问:为什么用 use() 而不是 useContext()

答:use() 是 React 19 的新 API,可以在条件语句和循环中调用(useContext 不行),还能直接读取 Promise(配合 Suspense)。

7.2 src/lib/chartTheme.ts:ECharts 6 主题

ts 复制代码
/**
 * ECharts 6 主题与色板
 * 现代深色科技风
 */

// 调色板(柔和现代色,用于图表数据)
export const palette = {
  // 主品牌色
  primary: '#00e0ff',      // 主青色
  secondary: '#d500f9',    // 紫
  success: '#69f0ae',      // 绿
  warning: '#ffab00',      // 琥珀
  danger: '#ff5252',       // 红
  warm: '#ff8a65',         // 暖橘

  // 数据指标色
  temp: '#ff8a65',         // 温度 - 橘
  humid: '#00b0ff',        // 湿度 - 浅蓝
  wind: '#69f0ae',         // 风 - 绿
  pressure: '#ff5252',     // 气压 - 红
  rain: '#448aff',         // 雨 - 蓝
  cloud: '#90a4ae',        // 云 - 灰
  purple: '#d500f9',       // 紫
  aqi: '#00e0ff',          // 空气质量 AQI 青色

  // 文字与背景
  textPrimary: '#f0f3fa',
  textSecondary: '#a3b1cc',
  textMuted: '#6b7a9a',
  textFaint: '#3a4761',
  border: 'rgba(105, 240, 174, 0.12)',
  cardBg: '#060a14',
  bgPage: '#050a1a',
};

/** 16 色风向调色板(玫瑰图用) */
export const windRosePalette = [
  '#69f0ae', '#26c6da', '#00b0ff', '#448aff',
  '#7c4dff', '#d500f9', '#ff4081', '#ff5252',
  '#ff6e40', '#ff8a65', '#ffab00', '#ffd54f',
  '#dce775', '#aed581', '#81c784', '#4db6ac',
];

/** 柔和面积渐变(垂直) */
export const areaGradient = (color: string) => ({
  type: 'linear',
  x: 0, y: 0, x2: 0, y2: 1,
  colorStops: [
    { offset: 0, color: color + 'cc' },
    { offset: 0.5, color: color + '55' },
    { offset: 1, color: color + '08' },
  ],
});

/** AQI 颜色映射(基于中国 AQI 标准 0-300) */
export function aqiColor(aqi: number): string {
  if (aqi <= 50) return '#69f0ae';     // 优 - 绿
  if (aqi <= 100) return '#ffeb3b';    // 良 - 黄
  if (aqi <= 150) return '#ffab00';    // 轻度污染 - 琥珀
  if (aqi <= 200) return '#ff6e40';    // 中度污染 - 橘
  if (aqi <= 300) return '#ff5252';    // 重度污染 - 红
  return '#b71c1c';                    // 严重污染 - 暗红
}

/** AQI 等级文字 */
export function aqiLevel(aqi: number): string {
  if (aqi <= 50) return '优';
  if (aqi <= 100) return '良';
  if (aqi <= 150) return '轻度污染';
  if (aqi <= 200) return '中度污染';
  if (aqi <= 300) return '重度污染';
  return '严重污染';
}

export const chartTheme = {
  backgroundColor: 'transparent',

  // 全局文字
  textStyle: {
    color: palette.textSecondary,
    fontFamily: 'Inter, "Noto Sans SC", sans-serif',
    fontSize: 11,
  },

  // 标题
  title: {
    textStyle: {
      color: palette.textPrimary,
      fontSize: 13,
      fontWeight: 600,
    },
    subtextStyle: {
      color: palette.textMuted,
      fontSize: 11,
    },
  },

  // 提示框(ECharts 6 完全重写)
  tooltip: {
    backgroundColor: 'rgba(6, 10, 20, 0.92)',
    borderColor: palette.cardBorder,
    borderWidth: 1,
    textStyle: { color: palette.textPrimary, fontSize: 12 },
    extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,.6); border-radius: 6px; backdrop-filter: blur(8px);',
  },

  // 图例
  legend: {
    textStyle: { color: palette.textSecondary, fontSize: 10 },
    itemWidth: 10,
    itemHeight: 6,
    itemGap: 12,
  },

  // 类目轴
  categoryAxis: {
    axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
    axisTick: { show: false },
    axisLabel: { color: palette.textMuted, fontSize: 10 },
    splitLine: { show: false },
  },

  // 数值轴
  valueAxis: {
    axisLine: { show: false },
    axisTick: { show: false },
    axisLabel: { color: palette.textMuted, fontSize: 10 },
    splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)', type: 'dashed' } },
  },
};

第 8 章 前端:ECharts 6 可视化组件开发

8.1 src/components/EChartsWrapper/ChartCanvas.tsx:自定义 ECharts 6 包装

教学说明:由于 echarts-for-react 与 ECharts 6 不兼容,我们手写一个更轻量的组件。

tsx 复制代码
/**
 * 自定义 ECharts 6 React 组件
 * - 不依赖 echarts-for-react(与 ECharts 6 不兼容)
 * - 使用 ResizeObserver 确保容器有尺寸才渲染
 * - 使用 ECharts 6 的 tree-shaking 模块导入
 * - 支持 option 变化时更新图表
 */

import { useEffect, useRef, useState, type ReactElement } from 'react';
import * as echarts from 'echarts/core';
import { LineChart, BarChart, ScatterChart, RadarChart } from 'echarts/charts';
import {
  GridComponent,
  TooltipComponent,
  TitleComponent,
  LegendComponent,
  PolarComponent,
  RadarComponent,
  MarkLineComponent,
  MarkPointComponent,
  DataZoomComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsCoreOption } from 'echarts/core';

// 注册 ECharts 6 模块(树摇优化)
echarts.use([
  LineChart,
  BarChart,
  ScatterChart,
  RadarChart,
  GridComponent,
  TooltipComponent,
  TitleComponent,
  LegendComponent,
  PolarComponent,
  RadarComponent,
  MarkLineComponent,
  MarkPointComponent,
  DataZoomComponent,
  CanvasRenderer,
]);

interface ChartCanvasProps {
  option: EChartsCoreOption;
  className?: string;
  style?: React.CSSProperties;
  opts?: {
    renderer?: 'canvas' | 'svg';
  };
}

export default function ChartCanvas({
  option,
  className = '',
  style,
  opts,
}: ChartCanvasProps): ReactElement | null {
  const containerRef = useRef<HTMLDivElement>(null);
  const chartRef = useRef<echarts.ECharts | null>(null);
  const [hasSize, setHasSize] = useState(false);

  // 监听尺寸变化
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return undefined;

    const checkSize = () => {
      if (el.clientWidth > 0 && el.clientHeight > 0) {
        setHasSize(true);
      }
    };
    checkSize();

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        if (width > 0 && height > 0) {
          setHasSize(true);
          // 容器尺寸变化时自动 resize
          chartRef.current?.resize();
        }
      }
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  // 初始化 / 更新图表
  useEffect(() => {
    if (!hasSize) return undefined;
    const el = containerRef.current;
    if (!el) return undefined;

    if (!chartRef.current) {
      chartRef.current = echarts.init(el, undefined, {
        renderer: opts?.renderer || 'canvas',
      });
    }

    chartRef.current.setOption(option, { notMerge: true });

    // 处理窗口大小变化
    const handleResize = () => chartRef.current?.resize();
    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
      chartRef.current?.dispose();
      chartRef.current = null;
    };
  }, [hasSize, option, opts?.renderer]);

  return (
    <div
      ref={containerRef}
      className={className}
      style={{ width: '100%', height: '100%', minHeight: '1px', ...style }}
    />
  );
}

课堂提问:为什么用 ResizeObserver 而不是直接渲染?

答:ECharts 需要容器有明确尺寸才能正确计算布局。ResizeObserver 确保容器有尺寸后才初始化图表,避免渲染异常。

8.2 Headersrc/components/Header/Header.tsx

要点:

  • 中间标题固定居中,左右两侧各设固定宽度;
  • 时间用 YYYY-MM-DD HH:mm:SS,加 tabular-nums 防宽度抖动。
tsx 复制代码
/**
 * 顶部导航- 深色科技 * 含渐变描+ 动态脉冲时 */

import { useEffect, useState } from 'react';
import { RefreshCw, Cloud, Search, Activity } from 'lucide-react';
import { useData } from '@/context/DataContext';

export default function Header() {
  const [currentTime, setCurrentTime] = useState(new Date());
  const { refreshData, loading } = useData();

  useEffect(() => {
    const timer = setInterval(() => setCurrentTime(new Date()), 1000);
    return () => clearInterval(timer);
  }, []);

  const pad = (n: number) => String(n).padStart(2, '0');
  const formatDate = (d: Date) =>
    `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}  ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  const weekDay = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][currentTime.getDay()];

  return (
    <header
      className="header-glow h-16 px-6 flex items-center justify-between shrink-0"
      style={{
        background: 'linear-gradient(180deg, rgba(19, 28, 51, 0.6) 0%, rgba(12, 20, 38, 0.4) 100%)',
        backdropFilter: 'blur(8px)',
      }}
    >
      {/* 左侧:品+ 城市 */}
      <div className="flex items-center gap-4">
        <div className="flex items-center gap-2.5">
          <div
            className="w-10 h-10 rounded-lg flex items-center justify-center relative"
            style={{
              background: 'linear-gradient(135deg, var(--brand-1), var(--brand-2))',
              boxShadow: '0 0 16px -2px var(--brand-1), inset 0 0 8px rgba(255, 255, 255, 0.15)',
            }}
          >
            <Cloud size={20} color="white" strokeWidth={2.4} />
            <div
              className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full"
              style={{ background: 'var(--brand-4)', boxShadow: '0 0 6px var(--brand-4)' }}
            />
          </div>
          <div>
            <div className="font-display text-[15px] font-semibold gradient-text">
              乌兰察布气象监测
            </div>
            <div className="text-[11px] flex items-center gap-1" style={{ color: 'var(--text-muted)' }}>
              <Activity size={9} />
              Ulanqab Weather Analytics
            </div>
          </div>
        </div>
        <div className="h-8 w-px" style={{ background: 'var(--border-medium)' }} />
        <div className="flex items-center gap-2">
          <span className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>乌兰察布市</span>
          <span className="dot dot-active" />
          <span className="text-[11px] tracking-wider uppercase" style={{ color: 'var(--brand-4)' }}>实时在线</span>
        </div>
      </div>

      {/* 中央:搜索 / 标签 */}
      <div className="flex items-center gap-2">
        <div
          className="flex items-center gap-2 px-3 py-1.5 rounded-md"
          style={{
            background: 'var(--bg-subtle)',
            border: '1px solid var(--border-light)',
          }}
        >
          <Search size={13} color="var(--text-muted)" />
          <span className="text-[12px]" style={{ color: 'var(--text-muted)' }}>搜索指标 / 区域 / 时间段</span>
          <span
            className="text-[10px] px-1.5 py-0.5 rounded ml-4 font-data"
            style={{
              background: 'var(--bg-card-2)',
              color: 'var(--text-muted)',
              border: '1px solid var(--border-light)',
            }}
          >
            K
          </span>
        </div>
      </div>

      {/* 右侧:时+ 操作 */}
      <div className="flex items-center gap-3">
        <div className="text-right">
          <div
            className="font-data text-[14px] font-medium text-glow"
            style={{ color: 'var(--text-primary)' }}
          >
            {formatDate(currentTime)}
          </div>
          <div className="text-[10px] tracking-wider" style={{ color: 'var(--text-muted)' }}>
            {weekDay} · CST
          </div>
        </div>
        <div className="h-8 w-px" style={{ background: 'var(--border-medium)' }} />
        <button
          type="button"
          onClick={(e) => { e.preventDefault(); e.stopPropagation(); void refreshData(); }}
          disabled={loading}
          className="btn btn-primary"
          title="刷新数据"
        >
          <RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
          <span>{loading ? '刷新中' : '刷新数据'}</span>
        </button>
      </div>
    </header>
  );
}

8.3 Footersrc/components/Footer/Footer.tsx

tsx 复制代码
/**
 * 底部信息- 深色科技 * 含扫描线动画 + 数据时间状 */

interface FooterProps {
  weatherUpdatedAt?: string;
  airQualityUpdatedAt?: string;
}

export default function Footer({ weatherUpdatedAt, airQualityUpdatedAt }: FooterProps) {
  const pad = (n: number) => String(n).padStart(2, '0');
  const formatTime = (isoString?: string) => {
    if (!isoString) return '--:--:--';
    const d = new Date(isoString);
    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  };

  return (
    <footer
      className="scan-line h-10 px-6 flex items-center justify-between text-[12px] shrink-0"
      style={{
        background: 'linear-gradient(180deg, rgba(12, 20, 38, 0.4) 0%, rgba(6, 10, 20, 0.8) 100%)',
        borderTop: '1px solid var(--border-light)',
        color: 'var(--text-muted)',
        position: 'relative',
      }}
    >
      <div className="flex items-center gap-3 relative z-10">
        <span className="tracking-wider uppercase text-[10px]" style={{ color: 'var(--text-faint)' }}>数据源</span>
        <span className="font-display font-medium" style={{ color: 'var(--text-secondary)' }}>Open-Meteo</span>
        <span style={{ color: 'var(--text-faint)' }}>·</span>
        <span>ECMWF</span>
        <span style={{ color: 'var(--text-faint)' }}>·</span>
        <span>中国气象局</span>
      </div>

      <div className="flex items-center gap-5 font-data relative z-10">
        <div className="flex items-center gap-1.5">
          <span className="dot" style={{ background: 'var(--color-temp)', boxShadow: '0 0 6px var(--color-temp)' }} />
          <span className="text-[11px] tracking-wider uppercase" style={{ color: 'var(--text-faint)' }}>气象</span>
          <span style={{ color: 'var(--text-primary)' }}>{formatTime(weatherUpdatedAt)}</span>
        </div>
        <div className="flex items-center gap-1.5">
          <span className="dot" style={{ background: 'var(--color-humid)', boxShadow: '0 0 6px var(--color-humid)' }} />
          <span className="text-[11px] tracking-wider uppercase" style={{ color: 'var(--text-faint)' }}>空气</span>
          <span style={{ color: 'var(--text-primary)' }}>{formatTime(airQualityUpdatedAt)}</span>
        </div>
        <div className="flex items-center gap-1.5">
          <span className="dot" style={{ background: 'var(--color-wind)', boxShadow: '0 0 6px var(--color-wind)' }} />
          <span className="text-[11px] tracking-wider uppercase" style={{ color: 'var(--text-faint)' }}>SSE</span>
          <span style={{ color: 'var(--text-primary)' }}>已连</span>
        </div>
      </div>
    </footer>
  );
}

8.4 WeatherCards:核心指标卡片组

tsx 复制代码
/**
 * 核心气象指标卡片- 深色科技 */

import type { WeatherData } from '../../../api/services/weatherService';
import { palette } from '@/lib/chartTheme';
import { Thermometer, Droplets, Wind, Gauge } from 'lucide-react';

interface WeatherCardsProps {
  data: WeatherData | null;
  loading: boolean;
}

interface KpiProps {
  icon: React.ReactNode;
  label: string;
  value: string | number;
  unit: string;
  color: string;   // 主色 (hex 6-char)
  accent: string;  // --accent 用的 var (e.g. 'var(--color-temp)')
  sub?: string;
  loading: boolean;
}

function KpiCard({ icon, label, value, unit, color, accent, sub, loading }: KpiProps) {
  if (loading) {
    return (
      <div className="kpi" style={{ '--accent': accent } as React.CSSProperties}>
        <div className="h-3 w-16 rounded animate-pulse" style={{ background: 'var(--bg-subtle)' }} />
        <div className="h-7 w-24 rounded animate-pulse" style={{ background: 'var(--bg-subtle)' }} />
        <div className="h-2.5 w-full rounded animate-pulse" style={{ background: 'var(--bg-subtle)' }} />
      </div>
    );
  }

  return (
    <div className="kpi" style={{ '--accent': accent } as React.CSSProperties}>
      <div className="flex items-center justify-between">
        <div className="kpi-label" style={{ color }}>{label}</div>
        <div
          className="w-7 h-7 rounded-md flex items-center justify-center"
          style={{
            background: `${color}22`,  // 13% alpha (8-char hex, valid on 6-char hex input)
            color,
            boxShadow: `inset 0 0 12px ${color}33`,
          }}
        >
          {icon}
        </div>
      </div>
      <div>
        <span className="kpi-value text-glow" style={{ color }}>{value}</span>
        <span className="kpi-unit">{unit}</span>
      </div>
      {sub && <div className="kpi-trend">{sub}</div>}
    </div>
  );
}

export default function WeatherCards({ data, loading }: WeatherCardsProps) {
  const humidity = data?.current.humidity ?? 0;
  const temp = data?.current.temperature ?? 0;
  const comfort = humidity > 70 ? '潮湿' : humidity < 30 ? '干燥' : '适宜';

  return (
    <div className="grid grid-cols-2 gap-3 h-full">
      <KpiCard
        icon={<Thermometer size={14} />}
        label="实时气温"
        value={data?.current.temperature ?? '--'}
        unit="°C"
        color={palette.temp}
        accent="var(--color-temp)"
        sub={`体感 ${Math.round(temp * 1.05)}°C`}
        loading={loading}
      />
      <KpiCard
        icon={<Droplets size={14} />}
        label="相对湿度"
        value={data?.current.humidity ?? '--'}
        unit="%"
        color={palette.humid}
        accent="var(--color-humid)"
        sub={`舒适度 · ${comfort}`}
        loading={loading}
      />
      <KpiCard
        icon={<Wind size={14} />}
        label="风速"
        value={data?.current.windSpeed ?? '--'}
        unit="km/h"
        color={palette.wind}
        accent="var(--color-wind)"
        sub={`风向 ${data?.current.windDirection ?? '--'}°`}
        loading={loading}
      />
      <KpiCard
        icon={<Gauge size={14} />}
        label="气压"
        value={data?.current.pressure ?? '--'}
        unit="hPa"
        color={palette.press}
        accent="var(--color-press)"
        sub={`降水 ${data?.current.precipitation ?? 0} mm`}
        loading={loading}
      />
    </div>
  );
}

8.5 AirQualityPanel:空气质量监测面板

tsx 复制代码
/**
 * 空气质量面板 - 深色科技 * 突出 AQI 圆形进度+ 4 个次级指(PM2.5/PM10/NO2/O3)
 */

import { useData } from '@/context/DataContext';
import { aqiColor, aqiLevel, palette } from '@/lib/chartTheme';

interface AirQualityPanelProps {
  loading: boolean;
}

interface MetricProps {
  label: string;
  value: number;
  unit: string;
  color: string;
  sub?: string;
  loading: boolean;
}

function MiniMetric({ label, value, unit, color, sub, loading }: MetricProps) {
  if (loading) {
    return (
      <div className="rounded-md p-2 animate-pulse"
        style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-light)', height: 64 }} />
    );
  }
  return (
    <div
      className="rounded-md px-2.5 py-2 flex flex-col gap-0.5"
      style={{
        background: 'var(--bg-card-2)',
        border: '1px solid var(--border-light)',
      }}
    >
      <div className="text-[10px] tracking-wider uppercase font-medium" style={{ color: palette.textMuted }}>
        {label}
      </div>
      <div className="flex items-baseline gap-1">
        <span className="font-data text-[16px] font-semibold text-glow" style={{ color }}>
          {value}
        </span>
        <span className="text-[10px]" style={{ color: palette.textMuted }}>{unit}</span>
      </div>
      {sub && <div className="text-[10px]" style={{ color: palette.textFaint }}>{sub}</div>}
    </div>
  );
}

export default function AirQualityPanel({ loading }: AirQualityPanelProps) {
  const { airQuality } = useData();
  const aqi = airQuality?.current.aqi ?? 0;
  const level = aqiLevel(aqi);
  const color = aqiColor(aqi);

  // 圆形进度 AQI 0-300 映射0-1
  const aqiRatio = Math.min(aqi / 300, 1);
  const RING_R = 42;
  const RING_C = 2 * Math.PI * RING_R;
  const ringDash = RING_C * aqiRatio;
  const ringGap = RING_C - ringDash;

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="section-title mb-3" style={{ '--accent': 'var(--brand-2)' } as React.CSSProperties}>
        空气质量监测
      </div>

      {loading ? (
        <div className="flex-1 flex items-center justify-center">
          <div className="text-[12px]" style={{ color: palette.textMuted }}>加载..</div>
        </div>
      ) : (
        <div className="flex-1 flex flex-col gap-3 min-h-0">
          {/* AQI 圆形进度*/}
          <div
            className="rounded-md flex items-center gap-4 px-4 py-3"
            style={{
              background: 'var(--bg-card-2)',
              border: '1px solid var(--border-light)',
              boxShadow: `inset 0 0 24px -8px ${color}33`,
            }}
          >
            <div className="relative shrink-0" style={{ width: 100, height: 100 }}>
              <svg width="100" height="100" viewBox="0 0 100 100" style={{ transform: 'rotate(-90deg)' }}>
                <circle
                  cx="50" cy="50" r={RING_R}
                  fill="none"
                  stroke="var(--border-medium)"
                  strokeWidth="6"
                />
                <circle
                  cx="50" cy="50" r={RING_R}
                  fill="none"
                  stroke={color}
                  strokeWidth="6"
                  strokeLinecap="round"
                  strokeDasharray={`${ringDash} ${ringGap}`}
                  style={{
                    filter: `drop-shadow(0 0 6px ${color})`,
                    transition: 'stroke-dasharray .8s ease',
                  }}
                />
              </svg>
              <div className="absolute inset-0 flex flex-col items-center justify-center">
                <span
                  className="font-data text-[24px] font-semibold leading-none text-glow"
                  style={{ color }}
                >
                  {aqi || '--'}
                </span>
                <span className="text-[10px] tracking-widest mt-1" style={{ color: palette.textMuted }}>AQI</span>
              </div>
            </div>
            <div className="flex-1 min-w-0">
              <div className="text-[12px] tracking-wider uppercase" style={{ color: palette.textMuted }}>等级</div>
              <div className="text-[20px] font-semibold mt-0.5 text-glow" style={{ color }}>
                {level}
              </div>
              <div className="text-[10px] mt-1 leading-relaxed" style={{ color: palette.textFaint }}>
                来自 Open-Meteo US AQI
              </div>
            </div>
          </div>

          {/* 4 个次级指2x2 */}
          <div className="grid grid-cols-2 gap-2">
            <MiniMetric
              label="PM2.5"
              value={airQuality?.current.pm25 ?? 0}
              unit="μg/m³"
              color={palette.humid}
              loading={loading}
            />
            <MiniMetric
              label="PM10"
              value={airQuality?.current.pm10 ?? 0}
              unit="μg/m³"
              color={palette.purple}
              loading={loading}
            />
            <MiniMetric
              label="NO₂"
              value={airQuality?.current.no2 ?? 0}
              unit="μg/m³"
              color={palette.warm}
              loading={loading}
            />
            <MiniMetric
              label="O₃"
              value={airQuality?.current.o3 ?? 0}
              unit="μg/m³"
              color={palette.wind}
              loading={loading}
            />
          </div>
        </div>
      )}
    </div>
  );
}

8.6 TemperatureChart:24h 温度趋势

tsx 复制代码
/**
 * 温度趋势折线图 - ECharts 6
 * 数据降级保障:当缺少逐小时温度数据时基于当前值生成
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { chartTheme, areaGradient, palette } from '@/lib/chartTheme';

interface TemperatureChartProps {
  data: WeatherData | null;
  loading: boolean;
}

function safeGetTempData(data: WeatherData | null) {
  const rawTimes = data?.hourly.time?.map(t => t.slice(11, 16)) || [];
  const rawTemps = data?.hourly.temperature || [];

  if (rawTemps.length >= 24 && rawTimes.length >= 24) {
    return { times: rawTimes, temps: rawTemps };
  }
  const base = data?.current.temperature ?? 20;
  const times = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
  const temps = Array.from({ length: 24 }, (_, i) => {
    const variation = 4 * Math.cos((i - 14) * Math.PI / 12);
    return Math.round((base + variation + (Math.random() - 0.5) * 1.5) * 10) / 10;
  });
  return { times, temps };
}

export default function TemperatureChart({ data, loading }: TemperatureChartProps) {
  const { times, temps } = safeGetTempData(data);

  const option = {
    ...chartTheme,
    grid: { top: 40, right: 24, bottom: 28, left: 48 },
    title: {
      ...chartTheme.title,
      text: '24 小时温度趋势',
      left: 16,
      top: 8,
    },
    tooltip: {
      ...chartTheme.tooltip,
      trigger: 'axis',
      formatter: (params: unknown) => {
        const arr = params as Array<{ axisValue: string; value: number }>
        const p = arr[0]
        return `<div style="font-size:11px;color:${palette.textMuted};margin-bottom:4px">${p.axisValue}</div>
                <div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${palette.temp};box-shadow:0 0 6px ${palette.temp};margin-right:6px"></span>温度 <b style="color:${palette.textPrimary}">${p.value}°C</b></div>`;
      },
    },
    xAxis: {
      type: 'category' as const,
      data: times,
      boundaryGap: false,
      ...chartTheme.categoryAxis,
    },
    yAxis: {
      type: 'value' as const,
      name: '°C',
      nameTextStyle: { color: palette.textFaint, fontSize: 10, padding: [0, 30, 0, 0] },
      min: (v: { min: number; max: number }) => Math.floor(v.min - 3),
      max: (v: { min: number; max: number }) => Math.ceil(v.max + 3),
      ...chartTheme.valueAxis,
    },
    series: [{
      name: '温度',
      type: 'line' as const,
      smooth: true,
      symbol: 'circle' as const,
      symbolSize: 5,
      showSymbol: false,
      data: temps,
      lineStyle: { width: 2.5, color: palette.temp, shadowColor: `${palette.temp}88`, shadowBlur: 12 },
      itemStyle: { color: palette.temp, borderColor: palette.cardBg, borderWidth: 2 },
      areaStyle: { color: areaGradient(palette.temp) },
      markPoint: {
        symbol: 'circle' as const,
        symbolSize: 30,
        data: [
          { type: 'max' as const, name: '高', itemStyle: { color: palette.temp, shadowColor: palette.temp, shadowBlur: 10 }, label: { color: '#060a14', fontSize: 10, fontWeight: 600 } },
          { type: 'min' as const, name: '低', itemStyle: { color: palette.humid, shadowColor: palette.humid, shadowBlur: 10 }, label: { color: '#060a14', fontSize: 10, fontWeight: 600 } },
        ],
      },
      markLine: {
        silent: true,
        symbol: 'none' as const,
        lineStyle: { color: palette.border, type: 'dashed' as const },
        label: { color: palette.textMuted, fontSize: 10 },
        data: [{ type: 'average' as const, name: '均值' }],
      },
    }],
    animationDuration: 1200,
  };

  if (loading) return <LoadingPlaceholder />;

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

8.7 PrecipitationChart:逐小时降水量

tsx 复制代码
/**
 * 逐小时降水量 - ECharts 6
 * 数据降级保障:缺少数据时基于当前值生成
 */

import type { TooltipFormatterCallback } from 'echarts/types/dist/shared.d.ts'
import type { WeatherData } from '../../../api/services/weatherService'
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas'
import { chartTheme, areaGradient, palette } from '@/lib/chartTheme'

interface PrecipitationChartProps {
  data: WeatherData | null
  loading: boolean
}

function safeGetPrecipData(data: WeatherData | null) {
  const rawTimes = data?.hourly.time?.map(t => t.slice(11, 16)) || []
  const rawPrecip = data?.hourly.precipitation || []

  if (rawPrecip.length >= 24 && rawTimes.length >= 24) {
    return { times: rawTimes, precip: rawPrecip }
  }
  const base = data?.current.precipitation ?? 0
  const times = Array.from({ length: 24 }, (_, index) => `${String(index).padStart(2, '0')}:00`)
  const precip = Array.from({ length: 24 }, () => {
    if (Math.random() < 0.7) return 0
    return Math.round((base + Math.random() * 3) * 10) / 10
  })
  return { times, precip }
}

export default function PrecipitationChart({ data, loading }: PrecipitationChartProps) {
  const { times, precip } = safeGetPrecipData(data)

  if (loading) return <LoadingPlaceholder />

  const option = {
    ...chartTheme,
    grid: { top: 40, right: 16, bottom: 28, left: 48 },
    title: {
      ...chartTheme.title,
      text: '逐小时降水量',
      left: 16,
      top: 8,
    },
    tooltip: {
      ...chartTheme.tooltip,
      trigger: 'axis',
      formatter: ((params: unknown) => {
        const arr = params as Array<{ axisValue: string; value: number }>
        const p = arr[0]
        return `<div style="font-size:11px;color:${palette.textMuted};margin-bottom:4px">${p.axisValue}</div>
                <div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${palette.rain};box-shadow:0 0 6px ${palette.rain};margin-right:6px"></span>降水量 <b style="color:${palette.textPrimary}">${p.value} mm</b></div>`
      }) as TooltipFormatterCallback<number>,
    },
    xAxis: {
      type: 'category' as const,
      data: times,
      ...chartTheme.categoryAxis,
      axisLabel: { ...chartTheme.categoryAxis.axisLabel, interval: 4 },
    },
    yAxis: {
      type: 'value' as const,
      name: 'mm',
      nameTextStyle: { color: palette.textFaint, fontSize: 10, padding: [0, 25, 0, 0] },
      ...chartTheme.valueAxis,
    },
    series: [{
      name: '降水量',
      type: 'bar' as const,
      barWidth: '50%',
      data: precip,
      itemStyle: {
        borderRadius: [4, 4, 0, 0],
        color: (rawParams: unknown) => {
          const params = rawParams as { value: number }
          const v = params.value
          if (v === 0) return 'rgba(255, 255, 255, 0.04)'
          if (v > 2) return areaGradient(palette.rain)
          return palette.rain
        },
      },
      emphasis: {
        itemStyle: { color: palette.rain, shadowBlur: 8, shadowColor: palette.rain + '66' },
      },
      label: {
        show: true,
        position: 'top' as const,
        formatter: (rawParams: unknown) => {
          const params = rawParams as { value: number }
          return params.value > 0 ? `${params.value}` : ''
        },
        color: palette.textSecondary,
        fontSize: 9,
      },
    }],
    animationDuration: 1500,
  }

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  )
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  )
}

8.8 TempHumidCombo:温湿度双轴

关键点:

  • 双 Y 轴:左温度 °C,右湿度 %;
  • 柱状 + 折线叠加。
tsx 复制代码
/**
 * 温湿度双轴组合图 - ECharts 6
 * 数据降级保障:缺少数据时基于当前值生成
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { chartTheme, areaGradient, palette } from '@/lib/chartTheme';

interface TempHumidComboProps {
  data: WeatherData | null;
  loading: boolean;
}

export default function TempHumidCombo({ data, loading }: TempHumidComboProps) {
  const temps = data?.hourly.temperature || [];
  const humidities = data?.hourly.humidity || [];
  const times = data?.hourly.time?.map(t => t.slice(11, 16)) || [];

  const hasRealData = temps.length > 0 && humidities.length > 0;
  const displayTemps = hasRealData ? temps : (() => {
    const base = data?.current.temperature ?? 20;
    return Array.from({ length: 24 }, (_, i) =>
      Math.round((base + Math.cos((i - 14) * Math.PI / 12) * 5 + (Math.random() - 0.5)) * 10) / 10
    );
  })();
  const displayHumids = hasRealData ? humidities : (() => {
    const base = data?.current.humidity ?? 50;
    return Array.from({ length: 24 }, (_, i) =>
      Math.max(20, Math.min(100, Math.round(base + Math.sin(i / 3) * 15 + (Math.random() - 0.5) * 8)))
    );
  })();
  const displayTimes = hasRealData && times.length >= 24
    ? times
    : Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);

  if (loading) return <LoadingPlaceholder />;

  const option = {
    ...chartTheme,
    tooltip: {
      ...chartTheme.tooltip,
      trigger: 'axis',
      formatter: (params: unknown) => {
        const arr = params as Array<{ axisValue: string; seriesName: string; value: number }>
        let s = `<div style="font-size:11px;color:${palette.textMuted};margin-bottom:6px">${arr[0].axisValue}</div>`;
        arr.forEach((p) => {
          const color = p.seriesName === '温度' ? palette.temp : palette.humid;
          const unit = p.seriesName === '温度' ? '°C' : '%';
          s += `<div style="margin:2px 0"><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color};margin-right:6px"></span>${p.seriesName} <b style="color:${palette.textPrimary}">${p.value}${unit}</b></div>`;
        });
        return s;
      },
    },
    legend: {
      ...chartTheme.legend,
      data: ['温度', '湿度'],
      top: 8,
      right: 16,
    },
    grid: { top: 44, right: 48, bottom: 28, left: 44 },
    xAxis: {
      type: 'category' as const,
      data: displayTimes,
      boundaryGap: true,
      ...chartTheme.categoryAxis,
      axisLabel: { ...chartTheme.categoryAxis.axisLabel, interval: 4 },
    },
    yAxis: [
      {
        type: 'value' as const,
        name: '°C',
        nameTextStyle: { color: palette.temp, fontSize: 10, padding: [0, 22, 0, 0] },
        position: 'left' as const,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: palette.textMuted, fontSize: 10 },
        splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)', type: 'dashed' as const } },
        min: (value: { min: number; max: number }) => Math.floor(value.min - 2),
        max: (value: { min: number; max: number }) => Math.ceil(value.max + 2),
      },
      {
        type: 'value' as const,
        name: '%',
        nameTextStyle: { color: palette.humid, fontSize: 10, padding: [0, 0, 0, 18] },
        position: 'right' as const,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: palette.textMuted, fontSize: 10 },
        splitLine: { show: false },
        min: 0,
        max: 100,
      },
    ],
    series: [
      {
        name: '湿度',
        type: 'bar' as const,
        yAxisIndex: 1,
        barWidth: '50%',
        barMaxWidth: 14,
        itemStyle: {
          borderRadius: [3, 3, 0, 0],
          color: palette.humid,
          opacity: 0.5,
        },
        emphasis: {
          itemStyle: { color: palette.humid, opacity: 0.8 },
        },
        data: displayHumids,
        animationDuration: 1200,
      },
      {
        name: '温度',
        type: 'line' as const,
        yAxisIndex: 0,
        smooth: true,
        symbol: 'circle' as const,
        symbolSize: 5,
        showSymbol: false,
        zlevel: 2,
        data: displayTemps,
        lineStyle: { width: 2.5, color: palette.temp },
        itemStyle: { color: palette.temp, borderColor: palette.cardBg, borderWidth: 2 },
        areaStyle: { color: areaGradient(palette.temp) },
        animationDuration: 1500,
      },
    ],
    animationDuration: 1500,
  };

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="section-title mb-2" style={{ '--accent': 'var(--color-temp)' } as React.CSSProperties}>
        温湿度双轴监测
      </div>
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

8.9 PressureChart:多指标趋势(气压 + 温度 + 湿度)

要点:三 Y 轴,气压主线 + 温度 / 湿度参考线。

tsx 复制代码
/**
 * 多指标趋势 - ECharts 6
 * 三轴:气压 hPa(左)、温度 °C(中)、湿度 %(右)
 * 数据降级保障:缺少数据时基于当前值生成
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { chartTheme, areaGradient, palette } from '@/lib/chartTheme';

interface PressureChartProps {
  data: WeatherData | null;
  loading: boolean;
}

function safeGetPressureData(data: WeatherData | null) {
  const rawTimes = data?.hourly.time?.map(t => t.slice(11, 16)) || [];
  const pressures = data?.hourly.pressure || [];
  const temperatures = data?.hourly.temperature || [];
  const humidities = data?.hourly.humidity || [];

  if (pressures.length >= 24 && rawTimes.length >= 24) {
    return {
      times: rawTimes,
      pressures: pressures.slice(0, 24),
      temperatures: temperatures.length >= 24 ? temperatures.slice(0, 24) : null,
      humidities: humidities.length >= 24 ? humidities.slice(0, 24) : null,
    };
  }

  const base = data?.current.pressure ?? 860;
  const times = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`);
  const pressuresFallback = Array.from({ length: 24 }, () => base + (Math.random() - 0.5) * 4);
  return { times, pressures: pressuresFallback, temperatures: null, humidities: null };
}

export default function PressureChart({ data, loading }: PressureChartProps) {
  const { times, pressures, temperatures, humidities } = safeGetPressureData(data);

  if (loading) return <LoadingPlaceholder />;

  const option = {
    ...chartTheme,
    legend: {
      ...chartTheme.legend,
      data: ['气压', '温度', '湿度'],
      top: 8,
      right: 16,
    },
    grid: { top: 44, right: 48, bottom: 28, left: 44 },
    tooltip: {
      ...chartTheme.tooltip,
      trigger: 'axis',
      axisPointer: { type: 'cross' as const, lineStyle: { color: palette.border } },
    },
    title: {
      ...chartTheme.title,
      text: '多指标趋势',
      left: 16,
      top: 8,
    },
    xAxis: {
      type: 'category' as const,
      data: times,
      boundaryGap: false,
      ...chartTheme.categoryAxis,
      axisLabel: { ...chartTheme.categoryAxis.axisLabel, interval: 4 },
    },
    yAxis: [
      {
        type: 'value' as const,
        name: 'hPa',
        nameTextStyle: { color: palette.pressure, fontSize: 10 },
        position: 'left' as const,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: palette.textMuted, fontSize: 10 },
        splitLine: { lineStyle: { color: 'rgba(255,255,255,0.04)', type: 'dashed' as const } },
        min: (value: { min: number; max: number }) => Math.floor(value.min - 5),
        max: (value: { min: number; max: number }) => Math.ceil(value.max + 5),
      },
      {
        type: 'value' as const,
        position: 'right' as const,
        name: '°C',
        nameTextStyle: { color: palette.temp, fontSize: 10 },
        offset: 0,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: palette.textMuted, fontSize: 10 },
        splitLine: { show: false },
        min: (value: { min: number; max: number }) => Math.floor(value.min - 2),
        max: (value: { min: number; max: number }) => Math.ceil(value.max + 2),
      },
      {
        type: 'value' as const,
        position: 'right' as const,
        name: '%',
        nameTextStyle: { color: palette.humid, fontSize: 10 },
        offset: 45,
        axisLine: { show: false },
        axisTick: { show: false },
        axisLabel: { color: palette.textMuted, fontSize: 10 },
        splitLine: { show: false },
        min: 0,
        max: 100,
      },
    ],
    series: [
      {
        name: '气压',
        type: 'line' as const,
        smooth: true,
        yAxisIndex: 0,
        symbol: 'none' as const,
        data: pressures,
        lineStyle: { width: 2.5, color: palette.pressure },
        areaStyle: { color: areaGradient(palette.pressure) },
        emphasis: { focus: 'series' as const },
      },
      {
        name: '温度',
        type: 'line' as const,
        smooth: true,
        yAxisIndex: 1,
        symbol: 'none' as const,
        data: temperatures,
        lineStyle: { width: 2, color: palette.temp, type: 'dashed' as const },
        emphasis: { focus: 'series' as const },
      },
      {
        name: '湿度',
        type: 'line' as const,
        smooth: true,
        yAxisIndex: 2,
        symbol: 'none' as const,
        data: humidities,
        lineStyle: { width: 2, color: palette.humid, type: 'dotted' as const },
        emphasis: { focus: 'series' as const },
      },
    ],
    animationDuration: 1500,
  };

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

8.10 RadarChart:气象综合雷达图

要点:把不同量纲的指标归一化到 0~100。

tsx 复制代码
/**
 * 气象综合分析 - ECharts 6
 * 基于真实数据归一化展示多维气象指标
 * 数据降级保障:使用当前值生成合理数据
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { palette } from '@/lib/chartTheme';

interface RadarChartProps {
  data: WeatherData | null;
  loading: boolean;
}

function normalizeMetrics(data: WeatherData | null) {
  if (!data) {
    return [50, 50, 50, 50, 30, 50];
  }
  const temp = ((data.current.temperature + 20) / 60) * 100;
  const humidity = data.current.humidity;
  const wind = (data.current.windSpeed / 20) * 100;
  const pressure = ((data.current.pressure - 850) / 30) * 100;
  const rain = (data.current.precipitation / 10) * 100;
  const windDir = (data.current.windDirection / 360) * 100;
  return [
    Math.max(0, Math.min(100, temp)),
    Math.max(0, Math.min(100, humidity)),
    Math.max(0, Math.min(100, wind)),
    Math.max(0, Math.min(100, pressure)),
    Math.max(0, Math.min(100, rain)),
    Math.max(0, Math.min(100, windDir)),
  ];
}

export default function RadarChart({ data, loading }: RadarChartProps) {
  if (loading) return <LoadingPlaceholder />;

  const values = normalizeMetrics(data);

  const option = {
    backgroundColor: 'transparent',
    textStyle: { color: palette.textSecondary, fontSize: 11 },
    title: {
      text: '气象综合分析',
      left: 16,
      top: 8,
      textStyle: { color: palette.textPrimary, fontSize: 13, fontWeight: 600 },
    },
    tooltip: {
      backgroundColor: 'rgba(6, 10, 20, 0.92)',
      borderColor: palette.cardBorder,
      borderWidth: 1,
      textStyle: { color: palette.textPrimary, fontSize: 12 },
      extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,.6); border-radius: 6px;',
      trigger: 'item',
      confine: true,
    },
    legend: {
      data: ['当前气象状态'],
      top: 8,
      right: 16,
      textStyle: { color: palette.textSecondary, fontSize: 10 },
      itemWidth: 10,
      itemHeight: 6,
    },
    radar: {
      indicator: [
        { name: '温度', max: 100 },
        { name: '湿度', max: 100 },
        { name: '风速', max: 100 },
        { name: '气压', max: 100 },
        { name: '降水', max: 100 },
        { name: '风向', max: 100 },
      ],
      shape: 'polygon' as const,
      center: ['50%', '56%'],
      radius: '62%',
      splitNumber: 4,
      axisName: { color: palette.textSecondary, fontSize: 10, fontWeight: 500 },
      splitArea: { areaStyle: { color: ['rgba(0, 224, 255, 0.02)', 'rgba(0, 224, 255, 0.04)'] } },
      axisLine: { lineStyle: { color: palette.border } },
      splitLine: { lineStyle: { color: palette.border } },
    },
    series: [
      {
        type: 'radar' as const,
        data: [
          {
            value: values,
            name: '当前气象状态',
            symbol: 'circle' as const,
            symbolSize: 6,
            lineStyle: { width: 2.5, color: palette.primary, shadowColor: palette.primary, shadowBlur: 10 },
            areaStyle: { color: 'rgba(0, 224, 255, 0.18)' },
            itemStyle: { color: palette.primary, borderColor: palette.cardBg, borderWidth: 2 },
          },
        ],
      },
    ],
    animationDuration: 1500,
  };

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

8.11 WindRoseChart:风向玫瑰图

要点:16 方位角度,按风速累计 + 极坐标柱状。

tsx 复制代码
/**
 * 风速风向玫瑰图 - ECharts 6
 * 关键:ECharts 6 polar 坐标必须完整定义 angleAxis + radiusAxis
 * 数据降级保障:当缺少逐小时风数据时基于当前值生成
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { palette, windRosePalette } from '@/lib/chartTheme';

interface WindRoseChartProps {
  data: WeatherData | null;
  loading: boolean;
}

// 风向标签(8方位)
const WIND_DIRECTIONS = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];

function getDirectionIndex(degrees: number): number {
  // 8 方位,每个方位 45 度
  const normalized = ((degrees + 22.5) % 360) / 45;
  return Math.floor(normalized) % 8;
}

function safeGetWindRoseData(data: WeatherData | null): number[] {
  const windDirs = data?.hourly.windDirection;
  const windSpeeds = data?.hourly.windSpeed;

  if (windDirs && windSpeeds && windDirs.length > 0 && windSpeeds.length > 0) {
    const buckets = new Array(8).fill(0);
    const counts = new Array(8).fill(0);
    for (let i = 0; i < windDirs.length; i++) {
      const idx = getDirectionIndex(windDirs[i]);
      buckets[idx] += windSpeeds[i];
      counts[idx]++;
    }
    // 平均风速
    return buckets.map((sum, i) => counts[i] > 0 ? sum / counts[i] : 0);
  }

  const currentSpeed = data?.current.windSpeed ?? 5;
  return Array.from({ length: 8 }, (_, i) => {
    const offset = Math.abs(i - getDirectionIndex(data?.current.windDirection ?? 0));
    return Math.max(0, currentSpeed * (1 - offset * 0.15) * (0.5 + Math.random() * 0.5));
  });
}

export default function WindRoseChart({ data, loading }: WindRoseChartProps) {
  const buckets = safeGetWindRoseData(data);
  const maxValue = Math.max(...buckets, 1);

  if (loading) return <LoadingPlaceholder />;

  // ECharts 6 关键修复:polar 必须有 radiusAxis + angleAxis,两个都明确指定 type
  const option = {
    backgroundColor: 'transparent',
    textStyle: { color: palette.textSecondary, fontSize: 11 },
    title: {
      text: '风向风速玫瑰',
      left: 16,
      top: 8,
      textStyle: { color: palette.textPrimary, fontSize: 13, fontWeight: 600 },
    },
    tooltip: {
      backgroundColor: 'rgba(6, 10, 20, 0.92)',
      borderColor: palette.cardBorder,
      borderWidth: 1,
      textStyle: { color: palette.textPrimary, fontSize: 12 },
      extraCssText: 'box-shadow: 0 4px 20px rgba(0,0,0,.6); border-radius: 6px;',
      trigger: 'item',
      formatter: (params: unknown) => {
        const p = params as { dataIndex: number; value: number }
        return `<div style="font-size:11px;color:${palette.textMuted};margin-bottom:4px">${WIND_DIRECTIONS[p.dataIndex]}</div>
                <div><b style="color:${palette.textPrimary}">${Number(p.value).toFixed(1)} km/h</b></div>`
      },
    },
    // 极坐标系统(ECharts 6 要求明确 axis 类型)
    polar: {
      center: ['50%', '56%'],
      radius: '64%',
    },
    angleAxis: {
      type: 'category' as const,
      data: WIND_DIRECTIONS,
      startAngle: 90,
      axisLine: { show: true, lineStyle: { color: palette.border } },
      axisTick: { show: false },
      axisLabel: { color: palette.textMuted, fontSize: 10 },
    },
    radiusAxis: {
      type: 'value' as const,
      min: 0,
      max: Math.ceil(maxValue * 1.2),
      axisLine: { show: false },
      axisTick: { show: false },
      axisLabel: { show: false },
      splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)', type: 'dashed' as const } },
    },
    series: [{
      name: '风速',
      type: 'bar' as const,
      data: buckets.map((v, i) => ({
        value: v,
        itemStyle: {
          color: windRosePalette[i],
          shadowColor: windRosePalette[i] + '66',
          shadowBlur: 6,
        },
      })),
      coordinateSystem: 'polar' as const,
      roundCap: true,
      barWidth: '60%',
      emphasis: {
        itemStyle: { shadowBlur: 12, shadowColor: palette.primary + '66' },
      },
    }],
    animationDuration: 1200,
  };

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

8.12 ScatterChart:温湿度相关性散点图

tsx 复制代码
/**
 * 温湿度相关性散点图 - ECharts 6
 * 数据降级保障:缺少数据时基于当前值生成
 */

import type { WeatherData } from '../../../api/services/weatherService';
import ChartCanvas from '@/components/EChartsWrapper/ChartCanvas';
import { chartTheme, palette } from '@/lib/chartTheme';

interface ScatterChartProps {
  data: WeatherData | null;
  loading: boolean;
}

function safeGetScatterData(data: WeatherData | null) {
  const temps = data?.hourly.temperature || [];
  const humids = data?.hourly.humidity || [];
  if (temps.length > 0 && humids.length > 0) {
    return temps.map((t, i) => [t, humids[i]]);
  }
  const base = data?.current.temperature ?? 20;
  return Array.from({ length: 24 }, () => {
    const t = base + (Math.random() - 0.5) * 6;
    const h = 50 + (Math.random() - 0.5) * 30;
    return [Math.round(t * 10) / 10, Math.round(h)];
  });
}

export default function ScatterChart({ data, loading }: ScatterChartProps) {
  const points = safeGetScatterData(data);

  if (loading) return <LoadingPlaceholder />;

  const option = {
    ...chartTheme,
    grid: { top: 40, right: 16, bottom: 32, left: 44 },
    title: {
      ...chartTheme.title,
      text: '温湿度相关性',
      left: 16,
      top: 8,
    },
    tooltip: {
      ...chartTheme.tooltip,
      trigger: 'item',
      formatter: (params: unknown) => {
        const p = params as { value: [number, number] }
        return `<b style="color:${palette.textPrimary}">${p.value[0]}°C</b>, <b style="color:${palette.textPrimary}">${p.value[1]}%</b>`
      },
    },
    xAxis: {
      type: 'value' as const,
      name: '°C',
      nameTextStyle: { color: palette.textFaint, fontSize: 10, padding: [0, 25, 0, 0] },
      nameLocation: 'end',
      ...chartTheme.valueAxis,
    },
    yAxis: {
      type: 'value' as const,
      name: '%',
      nameTextStyle: { color: palette.textFaint, fontSize: 10, padding: [0, 0, 0, 30] },
      nameLocation: 'end',
      ...chartTheme.valueAxis,
    },
    series: [{
      type: 'scatter' as const,
      data: points,
      symbolSize: 8,
      itemStyle: { color: palette.humid, opacity: 0.65, borderColor: palette.cardBg, borderWidth: 1 },
      emphasis: { itemStyle: { color: palette.primary, opacity: 1, shadowBlur: 10, shadowColor: palette.primary + '88' } },
    }],
    animationDuration: 1200,
  };

  return (
    <div className="card p-4 h-full flex flex-col">
      <div className="flex-1 min-h-0">
        <ChartCanvas option={option} />
      </div>
    </div>
  );
}

function LoadingPlaceholder() {
  return (
    <div className="card p-4 h-full flex items-center justify-center">
      <div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>加载中...</div>
    </div>
  );
}

第 9 章 前端:页面拼装与三栏布局

9.1 src/App.tsx:三栏 + 底部通栏布局

tsx 复制代码
/**
 * 乌兰察布市实时气象数据可视化大屏 - 深色科技风
 *
 * 三栏指挥中心布局:
 * - Header: 顶部固定导航栏
 * - Main: 三栏 (左 | 中 | 右) + 底部通栏
 *   - 左栏:  4 个 KPI 指标卡 + 风向玫瑰
 *   - 中栏:  温度大图 (上) + 气象综合雷达 (下)
 *   - 右栏:  空气质量面板 (上) + 温湿度双轴 (下)
 * - 底部通栏: 散点图 (左半) + 降水量 (右半)
 * - Footer: 底部信息条
 */

import { DataProvider, useData } from '@/context/DataContext';
import Header from '@/components/Header/Header';
import WeatherCards from '@/components/WeatherCards/WeatherCards';
import TemperatureChart from '@/components/TemperatureChart/TemperatureChart';
import PrecipitationChart from '@/components/PrecipitationChart/PrecipitationChart';
import TempHumidCombo from '@/components/TempHumidCombo/TempHumidCombo';
import RadarChart from '@/components/RadarChart/RadarChart';
import WindRoseChart from '@/components/WindRoseChart/WindRoseChart';
import ScatterChart from '@/components/ScatterChart/ScatterChart';
import PressureChart from '@/components/PressureChart/PressureChart';
import AirQualityPanel from '@/components/AirQualityPanel/AirQualityPanel';
import Footer from '@/components/Footer/Footer';

export default function App() {
  return (
    <DataProvider>
      <AppContent />
    </DataProvider>
  );
}

function AppContent() {
  const { weather, airQuality, loading } = useData();

  return (
    <div className="w-full h-full flex flex-col" style={{ background: 'var(--bg-page)' }}>
      <Header />

      <main
        className="flex-1 p-4 overflow-hidden"
        style={{
          display: 'grid',
          gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr)',
          gridTemplateRows: 'minmax(0, 1.6fr) minmax(0, 1fr)',
          gridTemplateAreas: `
            "left   center right"
            "bottom bottom bottom"
          `,
          gap: '14px',
          minHeight: 0,
        }}
      >
        {/* ====== 左栏: KPI + 风玫瑰 ====== */}
        <section
          className="min-h-0 flex flex-col"
          style={{
            gridArea: 'left',
            display: 'grid',
            gridTemplateRows: '38% 1fr',
            gap: '14px',
            minHeight: 0,
            overflow: 'hidden',
          }}
        >
          <div className="min-h-0 overflow-hidden">
            <WeatherCards data={weather} loading={loading} />
          </div>
          <div className="min-h-0 overflow-hidden">
            <WindRoseChart data={weather} loading={loading} />
          </div>
        </section>

        {/* ====== 中栏: 温度大图 + 综合雷达 ====== */}
        <section
          className="min-h-0 flex flex-col"
          style={{
            gridArea: 'center',
            display: 'grid',
            gridTemplateRows: 'minmax(0, 1.6fr) minmax(0, 1fr)',
            gap: '14px',
            minHeight: 0,
            overflow: 'hidden',
          }}
        >
          <div className="min-h-0 overflow-hidden">
            <TemperatureChart data={weather} loading={loading} />
          </div>
          <div className="min-h-0 overflow-hidden">
            <RadarChart data={weather} loading={loading} />
          </div>
        </section>

        {/* ====== 右栏: 空气质量 + 温湿度双轴 ====== */}
        <section
          className="min-h-0 flex flex-col"
          style={{
            gridArea: 'right',
            display: 'grid',
            gridTemplateRows: '55% 1fr',
            gap: '14px',
            minHeight: 0,
            overflow: 'hidden',
          }}
        >
          <div className="min-h-0 overflow-hidden">
            <AirQualityPanel loading={loading} />
          </div>
          <div className="min-h-0 overflow-hidden">
            <TempHumidCombo data={weather} loading={loading} />
          </div>
        </section>

        {/* ====== 底部通栏: 气压多指标 + 散点 + 降水 ====== */}
        <section
          className="min-h-0"
          style={{
            gridArea: 'bottom',
            display: 'grid',
            gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr)',
            gap: '14px',
            minHeight: 0,
            overflow: 'hidden',
          }}
        >
          <div className="min-h-0 overflow-hidden">
            <PressureChart data={weather} loading={loading} />
          </div>
          <div className="min-h-0 overflow-hidden">
            <ScatterChart data={weather} loading={loading} />
          </div>
          <div className="min-h-0 overflow-hidden">
            <PrecipitationChart data={weather} loading={loading} />
          </div>
        </section>
      </main>

      <Footer
        weatherUpdatedAt={weather?.updatedAt}
        airQualityUpdatedAt={airQuality?.updatedAt}
      />
    </div>
  );
}

9.2 数据流总结

复制代码
DataProvider (Context)
  ├─> SSE 订阅 /api/stream
  ├─> REST 兜底 /api/weather + /api/air-quality
  └─> 提供 { weather, airQuality, loading, refreshData }

useData() Hook
  ├─> Header (状态指示)
  ├─> WeatherCards (KPI 指标)
  ├─> TemperatureChart / PrecipitationChart / TempHumidCombo
  ├─> PressureChart / ScatterChart / RadarChart / WindRoseChart
  ├─> AirQualityPanel (空气质量)
  └─> Footer (更新时间)

第 10 章 启动、调试与常见问题

10.1 启动

bash 复制代码
npm install
npm run dev

预期日志:

复制代码
[0] VITE v8.x.x  ready in xxx ms
[0] ➜  Local: http://127.0.0.1:5173/
[1] Server ready on port 3001
[1] 正在初始化数据...
[1] 气象数据刷新成功
[1] 空气质量数据刷新成功
[1] 定时任务已配置: 每3分钟刷新数据

10.2 验证清单

检查项 操作 期望
健康检查 curl http://localhost:3001/api/health success: true
气象 REST curl http://localhost:3001/api/weather success: true,含 currenthourly
空气质量 REST curl http://localhost:3001/api/air-quality success: true,含 AQI / PM2.5 等
SSE 推送 curl -N http://localhost:3001/api/stream 立刻看到 event: weather / event: air-quality
浏览器 Network Filter: EventStream 出现 stream 请求持续接收

10.3 常见问题

Q1:EADDRINUSE: address already in use :::3001

A:另一个进程占用了 3001。Windows:

powershell 复制代码
Get-NetTCPConnection -LocalPort 3001 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }

Q2:前端 5173 打开是空白页

A:大概率后端没起来,F12 看控制台是否报 Failed to fetch /api/...。先确认 3001 健康检查返回 200。

Q3:PM2.5 显示 0

A:Open-Meteo 空气质量接口偶发失败时会回退到「全 0 占位」。可换时段再访问,或在 .env 加代理。

Q4:字体没生效

A:检查 index.css 顶部 Google Fonts 的 @import;或在内网环境替换为本地字体。

Q5:SSE 在某些反代会断

A:服务端每 25s 发送注释行作为心跳;如仍断,可在前端 hook 把重试退避时间调短。

Q6:ECharts 图表不显示

A:检查容器是否有明确尺寸(width: 100%; height: 100%),ChartCanvas 使用 ResizeObserver 确保容器有尺寸才渲染。


附录 A:完整文件清单

复制代码
.
├── api/
│   ├── app.ts                         # Express 5 应用工厂 + cron + SSE
│   ├── server.ts                      # 本地启动入口
│   ├── index.ts                       # Vercel Serverless 入口
│   ├── routes/
│   │   ├── airQuality.ts              # GET /api/air-quality
│   │   ├── auth.ts                    # 教学扩展:登录 / 注册 / 登出占位
│   │   └── weather.ts                 # GET /api/weather
│   ├── services/
│   │   ├── airQualityService.ts       # 空气质量采集 + 缓存 + 降级
│   │   └── weatherService.ts          # 气象采集 + 缓存 + 字段映射
│   └── sse/
│       └── broadcaster.ts             # SSE 客户端集合 + 心跳 + 广播
├── src/
│   ├── App.tsx                        # 大屏主布局(三栏 + 底部通栏)
│   ├── index.css                      # 设计系统:变量、动画、深色科技风
│   ├── main.tsx                       # React 入口
│   ├── vite-env.d.ts
│   ├── components/
│   │   ├── Footer/Footer.tsx          # 底部信息栏
│   │   ├── Header/Header.tsx          # 顶部标题 + 实时时钟
│   │   ├── AirQualityPanel/           # 新增:空气质量监测面板
│   │   ├── EChartsWrapper/            # 新增:自定义 ECharts 6 包装
│   │   │   └── ChartCanvas.tsx
│   │   ├── PrecipitationChart/PrecipitationChart.tsx
│   │   ├── PressureChart/PressureChart.tsx
│   │   ├── RadarChart/RadarChart.tsx
│   │   ├── ScatterChart/ScatterChart.tsx
│   │   ├── TempHumidCombo/TempHumidCombo.tsx
│   │   ├── TemperatureChart/TemperatureChart.tsx
│   │   ├── WeatherCards/WeatherCards.tsx
│   │   └── WindRoseChart/WindRoseChart.tsx
│   ├── context/                       # 新增:统一数据管理
│   │   └── DataContext.tsx            # React 19 Context + use() hook
│   ├── lib/
│   │   ├── useSse.ts                  # 通用 SSE hook(备用)
│   │   ├── chartTheme.ts              # ECharts 6 主题与色板
│   │   └── utils.ts                   # cn() 合并 className
│   ├── pages/
│   │   └── Home.tsx
│   └── assets/react.svg
├── public/favicon.svg
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── eslint.config.js                   # ESLint 9 flat config
└── nodemon.json

附录 B:教学任务卡(课后练习)

  1. 基础 :把默认端口 3001 改成环境变量 .env 读取,并解释为什么这样做。
  2. 进阶 :增加一个 /api/cities/:lat,:lon 接口,根据传入坐标返回该地当前气象,前端做一个城市切换下拉框。
  3. 挑战 :把 SSE 广播换成 WebSocket(ws 库),实现双向通信(前端可下发「刷新」指令)。
  4. 拓展 :把所有图表的颜色与设计系统颜色变量(--brand-*)绑定,演示「主题切换」。
  5. 实战 :部署到 Vercel,让 Cron 改成 Vercel Cron Jobs(参考 vercel.json)。

教学寄语:本项目是一个真实的「数据 → 接口 → 缓存 → 推送 → 可视化」完整闭环。建议教师按章节演示 + 学生按章节复刻,每章结束让学生用 5 分钟口述数据流走向,从「看得见」到「讲得出」。