- 总体分布

weather_config.py源代码
# -*- coding: utf-8 -*-
"""
weather_config.py
配置文件模块
存储所有全局配置信息:API密钥、邮箱设置、文件路径、阈值参数等。
本模块独立封装,方便修改而无需改动主程序代码。
"""import os# ============================================
# 和风天气 API 配置
# ============================================
# 和风天气开发者官网: https://dev.qweather.com/
# 使用前需注册账号并创建应用,获取免费版 API Key。
# 免费版支持:未来 3 天预报、实时天气、空气质量(AQI)等数据。
QWEATHER_API_KEY = "a4557fb819e4471e9b03f41adfbf26b6"
QWEATHER_API_HOST = "m9564w9rmc.re.qweatherapi.com"# 和风天气各接口地址(使用专属 API Host,2026 年起不再使用公共 Host)
QWEATHER_CITY_LOOKUP_URL = f"https://{QWEATHER_API_HOST}/geo/v2/city/lookup"
QWEATHER_HOURLY_URL = f"https://{QWEATHER_API_HOST}/v7/weather/24h"
QWEATHER_AIR_URL = f"https://{QWEATHER_API_HOST}/v7/air/now"# 目标城市(可修改为你所在的城市)
TARGET_CITY = "北京"
TARGET_CITY_ID = "101010100" # 北京的城市 Location ID,可从和风天气官网查询获取# ============================================
# 邮件 SMTP 配置
# ============================================
# 以 QQ 邮箱为例,SMTP 服务器地址:smtp.qq.com,端口 465(SSL)或 587(TLS)
# 需要在邮箱设置中开启 SMTP 服务并获取授权码(不是邮箱登录密码!)
SMTP_SERVER = "smtp.qq.com"
SMTP_PORT = 465 # SSL 端口(与 SMTP_SSL 配合使用)
SMTP_USER = "3862799071@qq.com" # 发送方邮箱地址
SMTP_PASSWORD = "bkstwtcekkksccbj" # 邮箱授权码(不是登录密码)# 接收方邮箱列表(可配置多个接收人,用逗号分隔)
RECEIVER_EMAILS = "3528753906@qq.com"# 邮件主题模板
EMAIL_SUBJECT_TEMPLATE = "【跑操提醒】{date} 早晨跑操通知"# ============================================
# 文件与路径配置
# ============================================
# 项目根目录(所有数据文件、日志文件、图表文件均存放于此)
BASE_DIR = r"D:\Python_Program\WeatherReport"# 各子目录路径
DATA_DIR = os.path.join(BASE_DIR, "data") # 历史天气数据 CSV 目录
CHART_DIR = os.path.join(BASE_DIR, "charts") # 温度折线图保存目录
LOG_DIR = os.path.join(BASE_DIR, "logs") # 日志文件目录# 确保目录存在(程序启动时自动创建)
for d in (DATA_DIR, CHART_DIR, LOG_DIR):os.makedirs(d, exist_ok=True)# 历史数据 CSV 文件路径(追加写入,每行一条记录)
HISTORY_CSV_PATH = os.path.join(DATA_DIR, "weather_history.csv")# 温度折线图保存路径(文件名含日期,便于归档)
CHART_PATH_TEMPLATE = os.path.join(CHART_DIR, "temperature_{date}.png")# 程序运行日志文件路径
LOG_PATH = os.path.join(LOG_DIR, "runner.log")# ============================================
# 出操判断阈值配置
# ============================================
# 以下阈值用于自动判断次日早晨 6:00 是否适合出操。
# 只要满足任一条件,即判定为“不出操”。# 极端低温阈值(摄氏度):低于此温度不出操
TEMPERATURE_MIN = 0# 极端高温阈值(摄氏度):高于此温度不出操
TEMPERATURE_MAX = 35# AQI(空气质量指数)阈值:大于此值不出操
AQI_MAX = 100# 大风阈值(风力等级,和风天气返回 1-17 级):大于此值不出操
WIND_LEVEL_MAX = 6# 风速阈值(公里/小时):大于此值不出操
WIND_SPEED_MAX = 30# 判定为“雨雪”的天气关键词列表(包含任一关键词即不出操)
# 和风天气的 weather text 字段会返回类似 "小雨"、"大雪"、"雷阵雨" 等文本
RAIN_SNOW_KEYWORDS = ["雨", "雪", "雾", "霾", "沙尘", "冰雹", "冻雨", "霜"
]# 早晨跑操时间(用于筛选 API 返回的逐小时数据中的目标时间点)
RUN_TIME_HOUR = 6 # 早上 6 点# ============================================
# UI 字体配置(使用微软雅黑)
# ============================================
UI_FONT_FAMILY = "微软雅黑"
UI_FONT_SIZE = 10
UI_FONT_TITLE = (UI_FONT_FAMILY, 14, "bold")
UI_FONT_LABEL = (UI_FONT_FAMILY, 11)
UI_FONT_BUTTON = (UI_FONT_FAMILY, 10)
UI_FONT_RESULT = (UI_FONT_FAMILY, 12, "bold")# ============================================
# 定时任务配置
# ============================================
# 每天自动检查的时间(24小时制,格式 HH:MM)
SCHEDULED_TIME = "17:00"# 定时任务轮询间隔(秒)
SCHEDULE_INTERVAL = 30# ============================================
# 辅助函数:打印当前配置摘要(调试用)
# ============================================
def print_config_summary():"""打印配置摘要,方便调试时确认配置已加载。"""print("=" * 50)print("【配置摘要】")print(f" 目标城市: {TARGET_CITY}")print(f" 数据目录: {DATA_DIR}")print(f" 图表目录: {CHART_DIR}")print(f" 日志目录: {LOG_DIR}")print(f" 发件邮箱: {SMTP_USER}")print(f" 收件邮箱: {RECEIVER_EMAILS}")print(f" 定时时间: 每天 {SCHEDULED_TIME}")print(f" 极端温度: {TEMPERATURE_MIN}°C ~ {TEMPERATURE_MAX}°C")print(f" AQI 阈值: > {AQI_MAX}")print(f" 风力阈值: > {WIND_LEVEL_MAX} 级")print("=" * 50)if __name__ == "__main__":print_config_summary()
weather_history.py源代码
# -*- coding: utf-8 -*-
"""
weather_history.py
历史数据管理模块
负责:1. 初始化 CSV 历史记录文件(含表头)。2. 将每次获取的天气数据追加写入 CSV。3. 读取历史数据,为温度折线图提供数据支撑。4. 提供日志记录功能(记录程序运行状态和异常信息)。
"""import csv
import os
import datetime
from weather_config import (HISTORY_CSV_PATH,LOG_PATH,DATA_DIR,LOG_DIR,
)# ============================================
# 初始化 CSV 文件(含表头)
# ============================================
_CSV_HEADER = ["date", # 日期,格式 YYYY-MM-DD"hour", # 小时,目标跑操时间(如 6)"temperature", # 温度(摄氏度)"weather_text", # 天气描述(如"晴"、"小雨")"wind_level", # 风力等级(1-17 级)"wind_speed", # 风速(公里/小时)"aqi", # 空气质量指数"aqi_category", # AQI 等级(优/良/轻度污染等)"decision", # 出操判定:"出操" 或 "不出操""reason", # 判定理由(多条用分号分隔)"timestamp", # 记录写入时间
]def ensure_data_dirs():"""确保数据目录和日志目录存在。程序启动时调用,防止后续写入操作因目录不存在而报错。"""os.makedirs(DATA_DIR, exist_ok=True)os.makedirs(LOG_DIR, exist_ok=True)def init_csv():"""初始化 CSV 文件。如果文件不存在,则创建并写入表头;如果文件已存在,不做任何操作(避免覆盖已有数据)。"""ensure_data_dirs()if not os.path.exists(HISTORY_CSV_PATH):with open(HISTORY_CSV_PATH, "w", newline="", encoding="utf-8-sig") as f:writer = csv.writer(f)writer.writerow(_CSV_HEADER)write_log(f"已创建历史数据文件: {HISTORY_CSV_PATH}")def append_record(date_str,hour,temperature,weather_text,wind_level,wind_speed,aqi,aqi_category,decision,reason,
):"""将一条天气记录追加到 CSV 历史文件中。参数说明:date_str (str): 日期,如 "2026-06-17"hour (int): 目标小时,如 6temperature (float): 温度(摄氏度)weather_text (str): 天气描述wind_level (str): 风力等级wind_speed (float): 风速(km/h)aqi (int): AQI 数值aqi_category (str): AQI 等级文本decision (str): "出操" 或 "不出操"reason (str): 判定理由描述"""init_csv()timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")row = [date_str,hour,temperature,weather_text,wind_level,wind_speed,aqi,aqi_category,decision,reason,timestamp,]with open(HISTORY_CSV_PATH, "a", newline="", encoding="utf-8-sig") as f:writer = csv.writer(f)writer.writerow(row)write_log(f"已追加历史记录: {date_str} {decision}")def read_all_history():"""读取全部历史数据,返回字典列表。每条记录为一个字典,键对应表头。如果文件不存在,返回空列表。返回:list[dict]: 所有历史记录"""init_csv()records = []with open(HISTORY_CSV_PATH, "r", encoding="utf-8-sig") as f:reader = csv.DictReader(f)for row in reader:records.append(row)return recordsdef read_recent_history(days=7):"""读取最近 N 天的历史记录,用于绘制温度变化折线图。按日期升序排列。参数:days (int): 读取最近多少天的记录,默认 7 天返回:list[dict]: 筛选后的记录列表"""all_records = read_all_history()# 按日期字符串降序排列,取最近 days 条,再按日期升序排列all_records_sorted = sorted(all_records, key=lambda x: x.get("date", ""), reverse=True)recent = all_records_sorted[:days]recent = sorted(recent, key=lambda x: x.get("date", ""))return recentdef write_log(message):"""写入日志文件。每条日志带时间戳。参数:message (str): 日志内容"""ensure_data_dirs()timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")line = f"[{timestamp}] {message}\n"with open(LOG_PATH, "a", encoding="utf-8") as f:f.write(line)
weather_runner.py源代码
import os
import sys
import json
import time
import csv
import datetime
import smtplib
import socket
import threading
import email.mime.multipart
import email.mime.text
import email.mime.base
from email import encoders# 第三方库
import requests
import matplotlib
matplotlib.use("Agg") # 使用非交互后端,避免在后台线程中弹出窗口
import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import schedule
import tkinter as tk # Python 标准库 GUI,Windows 11 自带,置于第三方库之后以明确分类# 本地模块
import weather_config as cfg
from weather_history import (append_record,read_recent_history,write_log,init_csv,ensure_data_dirs,
)# ============================================
# 全局字体设置:使用微软雅黑,确保中文正常显示
# ============================================
plt.rcParams["font.family"] = ["Microsoft YaHei", "SimHei", "sans-serif"]
plt.rcParams["axes.unicode_minus"] = False # 解决负号显示为方块的问题# ============================================
# 功能 1:天气数据爬取(和风天气 API)
# ============================================
class WeatherFetcher:"""天气数据爬取器。封装了和风天气 API 的调用逻辑,包括:- 获取城市 Location ID(基于城市名)- 获取逐小时预报(未来 24 小时)- 获取实时空气质量"""def __init__(self, api_key, city_id=None, city_name=None):self.api_key = api_keyself.city_id = city_idself.city_name = city_nameself.session = requests.Session()self.session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ""AppleWebKit/537.36 (KHTML, like Gecko) ""Chrome/126.0.0.0 Safari/537.36"})def _get(self, url, params):"""发送 GET 请求,并处理常见异常情况。使用 requests.get() 直接调用,与诊断脚本保持一致,确保最稳定。返回 JSON 数据(dict),请求失败返回 None。"""try:headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ""AppleWebKit/537.36 (KHTML, like Gecko) ""Chrome/126.0.0.0 Safari/537.36","X-QW-Api-Key": self.api_key,}response = requests.get(url, params=params, headers=headers, timeout=15)response.raise_for_status()data = response.json()# 和风天气的 403 错误可能返回 {"error": {"status": 403, ...}}if "error" in data:write_log(f"API 返回错误: {data.get('error', {}).get('title', 'Unknown')} | "f"{data.get('error', {}).get('detail', '')}")return Noneif data.get("code") != "200":write_log(f"API 返回错误码: {data.get('code')} | 信息: {data.get('status')}")return Nonereturn dataexcept requests.exceptions.RequestException as e:write_log(f"网络请求异常: {e}")return Nonedef fetch_city_id(self):"""通过城市名查询 Location ID。和风天气的逐小时预报接口需要 Location ID(如 101010100)。"""params = {"location": self.city_name,"key": self.api_key,}data = self._get(cfg.QWEATHER_CITY_LOOKUP_URL, params)if data and data.get("location"):self.city_id = data["location"][0]["id"]write_log(f"已获取城市 ID: {self.city_id} ({self.city_name})")return self.city_idreturn Nonedef fetch_hourly_forecast(self):"""获取未来 24 小时逐小时预报。返回:字典列表,每个元素包含 hour, temp, text, windScale, windSpeed 等字段。"""if not self.city_id:self.fetch_city_id()params = {"location": self.city_id,"key": self.api_key,}data = self._get(cfg.QWEATHER_HOURLY_URL, params)if not data or "hourly" not in data:return []hourly_list = data["hourly"]records = []for item in hourly_list:# 解析时间字符串,如 "2026-06-17T06:00+08:00"fx_time = item.get("fxTime", "")dt = datetime.datetime.fromisoformat(fx_time.replace("Z", "+00:00"))records.append({"datetime": dt,"date": dt.strftime("%Y-%m-%d"),"hour": dt.hour,"temperature": float(item.get("temp", 0)),"weather_text": item.get("text", ""),"wind_level": item.get("windScale", "0"),"wind_speed": float(item.get("windSpeed", 0)),"humidity": item.get("humidity", ""),"precip": item.get("precip", ""),})return recordsdef fetch_air_quality(self):"""获取当前城市的实时空气质量。返回:字典,包含 aqi, category, pm10, pm2p5 等字段。注意:免费版订阅可能不包含空气质量接口,返回 403 时给出友好提示。"""if not self.city_id:self.fetch_city_id()params = {"location": self.city_id,"key": self.api_key,}data = self._get(cfg.QWEATHER_AIR_URL, params)if not data or "now" not in data:# 免费版订阅可能不包含空气质量接口,返回占位值return {"aqi": -1, "category": "该订阅不包含空气质量数据", "pm10": -1, "pm2p5": -1}now = data["now"]return {"aqi": int(now.get("aqi", -1)),"category": now.get("category", "未知"),"pm10": int(now.get("pm10", -1)),"pm2p5": int(now.get("pm2p5", -1)),}def get_target_hour_weather(self, target_hour=6, target_date_offset=1):"""从逐小时预报中提取目标日期的目标小时数据。默认提取"明天" target_hour 点的数据(target_date_offset=1)。参数:target_hour (int): 目标小时(如 6 表示早上 6 点)target_date_offset (int): 日期偏移,1 表示明天,0 表示今天返回:dict or None: 匹配到的天气记录,无则返回 None"""today = datetime.date.today()target_date = today + datetime.timedelta(days=target_date_offset)target_date_str = target_date.strftime("%Y-%m-%d")hourly = self.fetch_hourly_forecast()for rec in hourly:if rec["date"] == target_date_str and rec["hour"] == target_hour:return recreturn None# ============================================
# 功能 2:出操智能判断
# ============================================
class WeatherDecision:"""出操判断器。根据用户配置的温度、天气、风力、AQI 阈值,综合判断次日早晨是否适合出操。"""def __init__(self, weather_rec, air_rec):"""参数:weather_rec (dict): WeatherFetcher.get_target_hour_weather() 返回的记录air_rec (dict): WeatherFetcher.fetch_air_quality() 返回的空气质量记录"""self.weather = weather_recself.air = air_recself.reasons = [] # 收集不出操的理由def judge(self):"""执行判断,返回二元组 (decision, reason)。decision: str, "出操" 或 "不出操"reason: str, 判定理由(可含多条,用分号分隔)"""if not self.weather:return "不出操", "无法获取天气数据,请检查网络或 API 配置。"temp = self.weather["temperature"]text = self.weather["weather_text"]wind_level = str(self.weather.get("wind_level", "0"))wind_speed = self.weather.get("wind_speed", 0)aqi = self.air.get("aqi", -1)# 检查极端低温if temp < cfg.TEMPERATURE_MIN:self.reasons.append(f"温度过低({temp}°C < {cfg.TEMPERATURE_MIN}°C),易引发感冒或冻伤")# 检查极端高温if temp > cfg.TEMPERATURE_MAX:self.reasons.append(f"温度过高({temp}°C > {cfg.TEMPERATURE_MAX}°C),存在中暑风险")# 检查雨雪雾霾等恶劣天气for keyword in cfg.RAIN_SNOW_KEYWORDS:if keyword in text:self.reasons.append(f"天气状况不佳({text}),不适合户外运动")break# 检查风力等级try:wl = int(wind_level.replace("级", "").split("-")[0])except ValueError:wl = 0if wl > cfg.WIND_LEVEL_MAX:self.reasons.append(f"风力过大({wind_level} > {cfg.WIND_LEVEL_MAX}级),易影响跑步安全")# 检查风速if wind_speed > cfg.WIND_SPEED_MAX:self.reasons.append(f"风速过快({wind_speed}km/h > {cfg.WIND_SPEED_MAX}km/h),体感不适")# 检查 AQI(当 aqi == -1 时,说明免费版订阅不包含空气质量接口,跳过检查)if aqi != -1 and aqi > cfg.AQI_MAX:self.reasons.append(f"空气质量差(AQI {aqi} > {cfg.AQI_MAX}),不利于呼吸健康")# 综合判定if self.reasons:decision = "不出操"reason = ";".join(self.reasons)else:aqi_display = aqi if aqi != -1 else "暂无数据"decision = "出操"reason = f"天气良好({text},{temp}°C,AQI {aqi_display}),请同学们及时到指定地点集合!"return decision, reason# ============================================
# 功能 3:温度变化折线图绘制
# ============================================
class TemperatureChart:"""温度折线图绘制器。基于历史 CSV 数据,绘制最近 N 天的温度变化趋势折线图。"""@staticmethoddef draw(days=7, save_path=None):"""绘制最近 days 天的温度变化折线图,并保存为 PNG 文件。参数:days (int): 取最近多少天的记录,默认 7 天save_path (str): 图片保存路径,为 None 时使用默认路径返回:str: 保存的图片路径,失败返回 None"""records = read_recent_history(days=days)if not records:write_log("历史数据不足,无法绘制折线图")return None# 提取日期和温度dates = [r["date"] for r in records]temps = [float(r["temperature"]) for r in records]# 创建画布fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)# 绘制折线 + 数据点标记ax.plot(dates, temps, color="#E74C3C", linewidth=2.5, marker="o", markersize=6, zorder=3)# 填充折线下方区域(半透明),增强视觉效果ax.fill_between(dates, temps, alpha=0.15, color="#E74C3C")# 在每个数据点上方标注温度数值for i, (d, t) in enumerate(zip(dates, temps)):ax.annotate(f"{t:.1f}°C",(i, t),textcoords="offset points",xytext=(0, 10),ha="center",fontsize=9,color="#333333",)# 设置标题与标签(使用微软雅黑字体)ax.set_title(f"近 {len(dates)} 天早晨 {cfg.RUN_TIME_HOUR}:00 温度变化趋势", fontsize=14, fontweight="bold", color="#2C3E50")ax.set_xlabel("日期", fontsize=11, color="#555555")ax.set_ylabel("温度 (°C)", fontsize=11, color="#555555")# 美化网格线ax.grid(True, linestyle="--", alpha=0.4, color="#999999")ax.set_axisbelow(True)# 旋转 x 轴标签,避免重叠plt.xticks(rotation=30, ha="right")# 设置背景色与边框ax.set_facecolor("#FAFAFA")fig.patch.set_facecolor("#FFFFFF")for spine in ax.spines.values():spine.set_color("#CCCCCC")# 紧凑布局plt.tight_layout()# 确定保存路径if not save_path:today_str = datetime.date.today().strftime("%Y%m%d")save_path = cfg.CHART_PATH_TEMPLATE.format(date=today_str)os.makedirs(os.path.dirname(save_path), exist_ok=True)plt.savefig(save_path, dpi=150, bbox_inches="tight")plt.close(fig)write_log(f"温度折线图已保存: {save_path}")return save_path# ============================================
# 功能 4:邮件自动发送(SMTP)
# ============================================
class MailSender:"""邮件发送器。使用 SMTP 协议发送 HTML 格式邮件,支持嵌入文字、表格及附件(折线图)。"""def __init__(self, smtp_server, smtp_port, user, password):self.smtp_server = smtp_serverself.smtp_port = smtp_portself.user = userself.password = passworddef send(self, receivers, subject, html_body, attachments=None):"""发送邮件。若网络不可用,将邮件内容保存到本地文件。"""if not receivers or "@" not in receivers:write_log("收件人邮箱未配置,跳过邮件发送")return Falsetry:msg = email.mime.multipart.MIMEMultipart()msg["From"] = self.usermsg["To"] = receiversmsg["Subject"] = subjectmsg.attach(email.mime.text.MIMEText(html_body, "html", "utf-8"))if attachments:for filepath in attachments:if os.path.exists(filepath):with open(filepath, "rb") as f:part = email.mime.base.MIMEBase("application", "octet-stream")part.set_payload(f.read())encoders.encode_base64(part)part.add_header("Content-Disposition",f"attachment; filename=\"{os.path.basename(filepath)}\"",)msg.attach(part)with smtplib.SMTP_SSL(self.smtp_server, self.smtp_port, timeout=15) as server:server.login(self.user, self.password)server.sendmail(self.user, receivers.split(","), msg.as_string())write_log(f"邮件已发送至: {receivers}")return Trueexcept (socket.gaierror, OSError) as e:# DNS 解析失败或网络不通,保存到本地文件write_log(f"网络不可用,邮件发送失败: {e}")self._save_email_to_local(subject, html_body, attachments)return Falseexcept smtplib.SMTPAuthenticationError as e:write_log(f"SMTP 认证失败,请检查邮箱授权码: {e}")except smtplib.SMTPException as e:write_log(f"SMTP 发送异常: {e}")except Exception as e:write_log(f"邮件发送失败(未知错误): {e}")return Falsedef _save_email_to_local(self, subject, html_body, attachments=None):"""当网络不可用时,将邮件内容保存到本地 HTML 文件,便于查看和手动发送。"""import datetimetimestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")filename = os.path.join(cfg.DATA_DIR, f"email_{timestamp}.html")full_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{subject}</title>
</head>
<body>
{html_body}
<hr>
<p style="color:#999;font-size:12px;">
【邮件备份】由于网络原因未能发送,保存于 {timestamp}<br>
发件人: {self.user}<br>
附件: {attachments if attachments else "无"}
</p>
</body>
</html>"""with open(filename, "w", encoding="utf-8") as f:f.write(full_html)write_log(f"邮件已保存到本地: {filename}")# ============================================
# 功能 5:UI 界面(tkinter)
# ============================================
class WeatherApp:"""主应用 UI 类。使用 tkinter 构建图形界面,包含:- 城市、API Key、邮箱设置输入框- 手动触发按钮(立即获取并分析)- 结果显示区域(文本 + 颜色标识)- 定时任务状态提示- 历史数据查看与图表预览"""def __init__(self, root):self.root = rootself.root.title("天气跑操提醒助手")self.root.geometry("720x580")self.root.configure(bg="#F5F6FA")self.root.resizable(False, False)# 居中窗口self.center_window()# 初始化各模块实例self.fetcher = WeatherFetcher(cfg.QWEATHER_API_KEY, cfg.TARGET_CITY_ID, cfg.TARGET_CITY)self.mailer = MailSender(cfg.SMTP_SERVER, cfg.SMTP_PORT, cfg.SMTP_USER, cfg.SMTP_PASSWORD)# 构建 UI 界面self._build_ui()# 启动后台定时线程self._start_scheduler_thread()def center_window(self):"""将窗口居中显示于屏幕。"""self.root.update_idletasks()width, height = 720, 580x = (self.root.winfo_screenwidth() // 2) - (width // 2)y = (self.root.winfo_screenheight() // 2) - (height // 2)self.root.geometry(f"{width}x{height}+{x}+{y}")def _build_ui(self):"""构建 tkinter 界面组件。"""font_title = cfg.UI_FONT_TITLEfont_label = cfg.UI_FONT_LABELfont_btn = cfg.UI_FONT_BUTTONfont_result = cfg.UI_FONT_RESULT# 顶部标题栏title_frame = tk.Frame(self.root, bg="#2C3E50", height=50)title_frame.pack(fill=tk.X)title_frame.pack_propagate(False)tk.Label(title_frame,text="☀ 天气跑操提醒助手",font=font_title,bg="#2C3E50",fg="#FFFFFF",).pack(side=tk.LEFT, padx=15, pady=8)# 主内容区域main_frame = tk.Frame(self.root, bg="#F5F6FA")main_frame.pack(fill=tk.BOTH, expand=True, padx=15, pady=10)# --- 配置信息区 ---cfg_frame = tk.LabelFrame(main_frame, text="配置信息", font=font_label, bg="#FFFFFF", fg="#333333", padx=10, pady=8)cfg_frame.pack(fill=tk.X, pady=5)tk.Label(cfg_frame, text="目标城市:", font=font_label, bg="#FFFFFF").grid(row=0, column=0, sticky=tk.W, pady=3)self.entry_city = tk.Entry(cfg_frame, width=20, font=font_label)self.entry_city.grid(row=0, column=1, sticky=tk.W, pady=3, padx=5)self.entry_city.insert(0, cfg.TARGET_CITY)tk.Label(cfg_frame, text="API Key:", font=font_label, bg="#FFFFFF").grid(row=0, column=2, sticky=tk.W, pady=3, padx=10)self.entry_key = tk.Entry(cfg_frame, width=30, font=font_label, show="*")self.entry_key.grid(row=0, column=3, sticky=tk.W, pady=3, padx=5)self.entry_key.insert(0, cfg.QWEATHER_API_KEY)tk.Label(cfg_frame, text="发件邮箱:", font=font_label, bg="#FFFFFF").grid(row=1, column=0, sticky=tk.W, pady=3)self.entry_sender = tk.Entry(cfg_frame, width=20, font=font_label)self.entry_sender.grid(row=1, column=1, sticky=tk.W, pady=3, padx=5)self.entry_sender.insert(0, cfg.SMTP_USER)tk.Label(cfg_frame, text="收件邮箱:", font=font_label, bg="#FFFFFF").grid(row=1, column=2, sticky=tk.W, pady=3, padx=10)self.entry_receiver = tk.Entry(cfg_frame, width=30, font=font_label)self.entry_receiver.grid(row=1, column=3, sticky=tk.W, pady=3, padx=5)self.entry_receiver.insert(0, cfg.RECEIVER_EMAILS)# --- 操作按钮区 ---btn_frame = tk.Frame(main_frame, bg="#F5F6FA")btn_frame.pack(fill=tk.X, pady=8)self.btn_run = tk.Button(btn_frame,text="立即检查明天天气",font=font_btn,bg="#3498DB",fg="#FFFFFF",activebackground="#2980B9",width=18,height=2,cursor="hand2",command=self.on_run_now,)self.btn_run.pack(side=tk.LEFT, padx=5)self.btn_chart = tk.Button(btn_frame,text="查看温度折线图",font=font_btn,bg="#2ECC71",fg="#FFFFFF",activebackground="#27AE60",width=18,height=2,cursor="hand2",command=self.on_show_chart,)self.btn_chart.pack(side=tk.LEFT, padx=5)self.btn_history = tk.Button(btn_frame,text="查看历史数据",font=font_btn,bg="#9B59B6",fg="#FFFFFF",activebackground="#8E44AD",width=18,height=2,cursor="hand2",command=self.on_show_history,)self.btn_history.pack(side=tk.LEFT, padx=5)# --- 状态提示区 ---self.status_var = tk.StringVar(value="定时任务已启动:每天 17:00 自动检查")status_label = tk.Label(main_frame,textvariable=self.status_var,font=(cfg.UI_FONT_FAMILY, 9),bg="#F5F6FA",fg="#666666",)status_label.pack(anchor=tk.W, pady=5)# --- 结果显示区 ---result_frame = tk.LabelFrame(main_frame, text="检查结果", font=font_label, bg="#FFFFFF", fg="#333333", padx=10, pady=8)result_frame.pack(fill=tk.BOTH, expand=True, pady=5)self.result_text = tk.Text(result_frame,font=font_result,bg="#FFFFFF",fg="#333333",wrap=tk.WORD,height=14,state=tk.DISABLED,)self.result_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)scrollbar = tk.Scrollbar(result_frame, command=self.result_text.yview)scrollbar.pack(side=tk.RIGHT, fill=tk.Y)self.result_text.config(yscrollcommand=scrollbar.set)def _start_scheduler_thread(self):"""启动后台线程,运行 schedule 定时任务。"""def scheduler_loop():# 每天 17:00 执行一次自动检查schedule.every().day.at(cfg.SCHEDULED_TIME).do(self._scheduled_task)write_log(f"定时任务已注册:每天 {cfg.SCHEDULED_TIME} 执行")while True:schedule.run_pending()time.sleep(cfg.SCHEDULE_INTERVAL)t = threading.Thread(target=scheduler_loop, daemon=True)t.start()def _scheduled_task(self):"""定时任务回调函数:每天 17:00 自动执行。"""write_log("定时任务触发:开始自动检查天气...")self.status_var.set(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] 正在执行定时检查...")try:self._execute_check(send_email=True)self.status_var.set(f"定时检查完成:{datetime.datetime.now().strftime('%H:%M:%S')}")except Exception as e:write_log(f"定时任务异常: {e}")self.status_var.set(f"定时检查失败: {e}")def on_run_now(self):"""点击【立即检查】按钮时的回调。"""self.btn_run.config(state=tk.DISABLED, text="检查中...")self.root.update()try:self._execute_check(send_email=True)except Exception as e:self._update_result(f"运行出错:{e}", error=True)write_log(f"手动运行异常: {e}")finally:self.btn_run.config(state=tk.NORMAL, text="立即检查明天天气")def _execute_check(self, send_email=False):"""执行核心检查逻辑:1. 更新配置(从 UI 输入框读取最新值)2. 获取天气数据与空气质量3. 出操判断4. 保存历史数据5. 绘制温度折线图6. 发送邮件(若 send_email=True)"""# 1. 更新配置city = self.entry_city.get().strip() or cfg.TARGET_CITYkey = self.entry_key.get().strip() or cfg.QWEATHER_API_KEYsender = self.entry_sender.get().strip() or cfg.SMTP_USERreceiver = self.entry_receiver.get().strip() or cfg.RECEIVER_EMAILSself.fetcher.city_name = cityself.fetcher.api_key = keyself.mailer.user = senderself.mailer.password = cfg.SMTP_PASSWORD # 授权码从配置文件读取,UI 不显示# 2. 获取天气数据target_hour = cfg.RUN_TIME_HOURweather_rec = self.fetcher.get_target_hour_weather(target_hour=target_hour, target_date_offset=1)air_rec = self.fetcher.fetch_air_quality()# 3. 出操判断decider = WeatherDecision(weather_rec, air_rec)decision, reason = decider.judge()# 4. 保存历史数据if weather_rec:append_record(date_str=weather_rec["date"],hour=weather_rec["hour"],temperature=weather_rec["temperature"],weather_text=weather_rec["weather_text"],wind_level=weather_rec.get("wind_level", "0"),wind_speed=weather_rec.get("wind_speed", 0),aqi=air_rec.get("aqi", -1),aqi_category=air_rec.get("category", "未知"),decision=decision,reason=reason,)# 5. 绘制温度折线图chart_path = TemperatureChart.draw()# 6. 组装结果文本if weather_rec:aqi_val = air_rec.get('aqi', '未知')aqi_cat = air_rec.get('category', '未知')if aqi_val == -1:aqi_display = "暂无数据(免费版订阅不包含空气质量接口)"aqi_cat_display = "—"else:aqi_display = f"{aqi_val}"aqi_cat_display = aqi_catresult_lines = [f"【查询日期】{weather_rec['date']} {target_hour}:00",f"【天气状况】{weather_rec['weather_text']}",f"【温度】{weather_rec['temperature']} °C",f"【风力】{weather_rec.get('wind_level', '0')} 级 / 风速 {weather_rec.get('wind_speed', 0)} km/h",f"【AQI】{aqi_display} ({aqi_cat_display})","",f"【判定结果】{decision}",f"【详细理由】{reason}",]else:result_lines = ["【查询失败】无法获取天气数据。","请检查以下事项:"," 1. 网络连接是否正常;"," 2. API Key 是否正确(和风天气开发者平台申请);"," 3. 目标城市名称是否填写正确。",]result_text = "\n".join(result_lines)self._update_result(result_text, error=(decision == "不出操" or not weather_rec))# 7. 发送邮件(若需要)if send_email and weather_rec and receiver:subject = cfg.EMAIL_SUBJECT_TEMPLATE.format(date=weather_rec["date"])html_body = self._build_email_html(weather_rec, air_rec, decision, reason)attachments = [chart_path] if chart_path else Noneself.mailer.send(receiver, subject, html_body, attachments)def _build_email_html(self, weather_rec, air_rec, decision, reason):"""构建 HTML 格式的邮件正文,包含表格和样式,美观清晰。"""aqi_val = air_rec.get('aqi', '未知')aqi_cat = air_rec.get('category', '未知')if aqi_val == -1:aqi_display = "暂无数据"aqi_cat_display = "免费版不包含空气质量接口"else:aqi_display = f"{aqi_val}"aqi_cat_display = aqi_catcolor = "#27AE60" if decision == "出操" else "#E74C3C"card = f"""<div style="max-width:600px;margin:20px auto;padding:20px;border:1px solid #ddd;border-radius:8px;font-family:Microsoft YaHei,sans-serif;"><h2 style="color:#2C3E50;text-align:center;">🌤 明日跑操提醒</h2><table style="width:100%;border-collapse:collapse;margin:15px 0;"><tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">日期</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['date']} {cfg.RUN_TIME_HOUR}:00</td></tr><tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">天气</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['weather_text']}</td></tr><tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">温度</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec['temperature']} °C</td></tr><tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">风力</td><td style="padding:8px;border-bottom:1px solid #eee;">{weather_rec.get('wind_level', '0')} 级</td></tr><tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">AQI</td><td style="padding:8px;border-bottom:1px solid #eee;">{aqi_display} ({aqi_cat_display})</td></tr></table><div style="text-align:center;padding:15px;background-color:{color};color:#fff;border-radius:6px;font-size:18px;font-weight:bold;">{decision}</div><p style="margin-top:15px;color:#555;font-size:14px;line-height:1.6;"><strong>判定理由:</strong>{reason}</p><hr style="border:none;border-top:1px solid #eee;margin:15px 0;"><p style="font-size:12px;color:#999;text-align:center;">本邮件由 天气跑操提醒助手 自动发送<br>发送时间:{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p></div>"""return carddef _update_result(self, text, error=False):"""更新结果显示区的文本,并根据是否出错改变文字颜色。"""self.result_text.config(state=tk.NORMAL)self.result_text.delete("1.0", tk.END)color = "#E74C3C" if error else "#2C3E50"self.result_text.config(fg=color)self.result_text.insert(tk.END, text)self.result_text.config(state=tk.DISABLED)def on_show_chart(self):"""点击【查看温度折线图】按钮:打开图表文件夹。"""chart_path = TemperatureChart.draw()if chart_path:self._update_result(f"温度折线图已生成,保存路径:\n{chart_path}\n\n正在打开图片...")# 使用系统默认程序打开图片os.startfile(chart_path)else:self._update_result("暂无足够历史数据,请先运行一次天气检查。", error=True)def on_show_history(self):"""点击【查看历史数据】按钮:在结果区展示最近 10 条记录。"""records = read_recent_history(days=30)if not records:self._update_result("暂无历史数据。", error=True)returnlines = ["【最近历史记录】\n"]for r in records[-10:]:aqi_hist = r['aqi']aqi_hist_display = "暂无" if aqi_hist == "-1" or aqi_hist == -1 else f"AQI {aqi_hist}"lines.append(f"{r['date']} {r['hour']}:00 | "f"{r['weather_text']} {r['temperature']}°C | "f"{aqi_hist_display} | "f"判定:{r['decision']}")self._update_result("\n".join(lines))# ============================================
# 程序入口
# ============================================
if __name__ == "__main__":# 确保目录存在ensure_data_dirs()init_csv()write_log("=" * 40)write_log("程序启动")cfg.print_config_summary()# 检查 tkinter 可用性(已在顶部导入,此处验证环境完整性)try:tk._test() # 利用 tkinter 模块存在性确认环境可用except AttributeError:pass # 正常情况,无需处理except Exception as e:print(f"错误:tkinter 环境异常: {e}")print("建议:在 Windows 上请使用标准安装包安装 Python(勾选 tcl/tk 选项)。")sys.exit(1)# 启动主窗口root = tk.Tk()app = WeatherApp(root)root.mainloop()