From 89dc6b44495382f9e5c9680d9aabd6c96267e272 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 8 Jul 2026 14:36:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=82=AE=E4=BB=B6=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20+=20=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /email 配置页面 + /api/email/config API (GET/POST) - 新增 /api/email/test-send 测试邮件 + /api/email/reset 重置 - EmailConfig SQLite 表持久化配置,回退到环境变量 - mailer: get_config 优先读 DB,send_report 读取 DB 密码 - 修复 _send_smtp 登录使用环境变量密码的 bug - 前端新增配置表单 (启用开关/SMTP/TLS/认证/发件人/收件人/时间) - CSS 暗色主题样式 + 响应式适配 - 导航栏新增「设置」链接 --- .gitignore | 4 + app.py | 87 +++++++++- instance/palladium.db | Bin 20480 -> 0 bytes mailer.py | 150 +++++++++++++++- static/style.css | 226 ++++++++++++++++++++++++ templates/base.html | 1 + templates/email_config.html | 330 ++++++++++++++++++++++++++++++++++++ 7 files changed, 788 insertions(+), 10 deletions(-) create mode 100644 .gitignore delete mode 100644 instance/palladium.db create mode 100644 templates/email_config.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3982aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +instance/ +__pycache__/ +*.pyc +.env diff --git a/app.py b/app.py index 0d789a9..0f790b8 100644 --- a/app.py +++ b/app.py @@ -700,6 +700,17 @@ def cleanup_old(): return jsonify({"ok": True, "deleted": count}) +# ═══════════════════════════════════════════════════════════════════════════ +# EmailConfig — 邮件配置持久化 (Web UI 写入, mailer 读取) +# ═══════════════════════════════════════════════════════════════════════════ +class EmailConfig(db.Model): + __tablename__ = 'email_config' + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(50), unique=True, nullable=False) + value = db.Column(db.Text, nullable=False, default='') + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # ═══════════════════════════════════════════════════════════════════════════ # Email Routes (延迟导入 mailer, 避免在 mailer 缺失时整个 app 起不来) # ═══════════════════════════════════════════════════════════════════════════ @@ -756,6 +767,80 @@ def email_preview(): headers={"Content-Disposition": 'inline; filename="mail-preview.txt"'}) +@app.route("/email") +def email_config(): + """邮件配置页面。""" + return render_template("email_config.html") + + +@app.route("/api/email/config") +def email_config_api(): + """获取完整邮件配置 (含密码, 仅本地管理用)。""" + ml = _get_mailer() + if not ml: + return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500 + cfg = ml.get_config_full() + errs = ml.config_errors(cfg) + return jsonify({ + "ok": True, + "configured": len(errs) == 0, + "config_errors": errs, + **cfg, + }) + + +@app.route("/api/email/config", methods=["POST"]) +def email_config_save(): + """保存邮件配置到数据库。""" + data = request.get_json(silent=True) or {} + ml = _get_mailer() + if not ml: + return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500 + try: + saved = ml.save_config(data) + except Exception as e: + return jsonify({"ok": False, "error": f"保存失败: {e}"}), 500 + cfg = ml.get_config_full() + errs = ml.config_errors(cfg) + return jsonify({ + "ok": True, + "saved": saved, + "configured": len(errs) == 0, + "config_errors": errs, + }) + + +@app.route("/api/email/reset", methods=["POST"]) +def email_config_reset(): + """清除数据库配置, 回退到环境变量。""" + ml = _get_mailer() + if not ml: + return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500 + try: + ml.reset_config() + except Exception as e: + return jsonify({"ok": False, "error": f"重置失败: {e}"}), 500 + cfg = ml.get_config_full() + errs = ml.config_errors(cfg) + return jsonify({ + "ok": True, + "message": "已清除数据库配置, 回退到环境变量", + "configured": len(errs) == 0, + "config_errors": errs, + }) + + +@app.route("/api/email/test-send", methods=["POST"]) +def email_test_send(): + """发送测试邮件 (从 UI 手动触发)。""" + ml = _get_mailer() + if not ml: + return jsonify({"ok": False, "error": "mailer.py 不可用"}), 500 + result = ml.send_report() + status = 200 if result["ok"] else 500 + return jsonify(result), status + + @app.route("/api/email/send", methods=["POST"]) def email_send(): """ @@ -798,7 +883,7 @@ def _scheduler_loop(): print("[scheduler] 邮件调度线程已启动", flush=True) while True: try: - cfg = mailer.get_config() + cfg = mailer.get_config_full() if not cfg["enabled"]: # 关闭, 每小时看一眼 time.sleep(3600) diff --git a/instance/palladium.db b/instance/palladium.db deleted file mode 100644 index d45360cfb595570dd5d3089029c15af90ad8e784..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20480 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 M+V9zSxkvH;1A)YHYXATM diff --git a/mailer.py b/mailer.py index d25990d..5e4eb89 100644 --- a/mailer.py +++ b/mailer.py @@ -34,19 +34,46 @@ def _bool(name: str, default: bool = False) -> bool: return default -def _int(name: str, default: int) -> int: +def _int_env(name: str, default: int) -> int: try: return int(os.environ.get(name, "").strip()) except (TypeError, ValueError): return default -def get_config() -> dict: - """读取并返回当前生效的邮件配置 (用于 /api/email/status 显示)。""" +# ═══════════════════════════════════════════════════════════════════════════ +# 数据库配置持久化 +# ═══════════════════════════════════════════════════════════════════════════ +def _get_email_config_model(): + """惰性获取 EmailConfig 模型 (避免循环导入)。""" + try: + from app import EmailConfig + return EmailConfig + except (ImportError, AttributeError): + return None + + +def _read_db_config() -> Optional[Dict[str, str]]: + """从 EmailConfig 表读取配置, 返回 {key: value} dict, 无记录返回 None。""" + Model = _get_email_config_model() + if not Model: + return None + try: + with app.app_context(): + rows = Model.query.all() + if not rows: + return None + return {r.key: r.value for r in rows} + except Exception: + return None + + +def _build_config_from_env() -> dict: + """从环境变量读取配置 (原始逻辑)。""" return { "enabled": _bool("MAIL_ENABLED", False), "smtp_host": os.environ.get("SMTP_HOST", "").strip(), - "smtp_port": _int("SMTP_PORT", 587), + "smtp_port": _int_env("SMTP_PORT", 587), "smtp_user": os.environ.get("SMTP_USER", "").strip(), "smtp_password_set": bool(os.environ.get("SMTP_PASSWORD", "").strip()), "smtp_use_tls": _bool("SMTP_USE_TLS", True), @@ -54,12 +81,113 @@ def get_config() -> dict: "from_addr": os.environ.get("MAIL_FROM", "").strip(), "from_name": os.environ.get("MAIL_FROM_NAME", "Palladium Z1 Monitor").strip(), "to_addrs": [a.strip() for a in os.environ.get("MAIL_TO", "").split(",") if a.strip()], - "send_hour": _int("MAIL_SEND_HOUR", 9), # 每天 09:00 - "send_minute": _int("MAIL_SEND_MINUTE", 0), + "send_hour": _int_env("MAIL_SEND_HOUR", 9), + "send_minute": _int_env("MAIL_SEND_MINUTE", 0), "monitor_base_url": os.environ.get("MONITOR_BASE_URL", "http://127.0.0.1:5100").strip(), } +def _build_config_from_dict(raw: Dict[str, str]) -> dict: + """从数据库 {key: value} dict 构建配置。""" + def _s(key: str, default: str = "") -> str: + return raw.get(key, default).strip() + def _b(key: str, default: bool) -> bool: + v = raw.get(key, "").strip().lower() + return v in ("1", "true", "yes", "y", "on") + def _i(key: str, default: int) -> int: + try: + return int(raw.get(key, str(default))) + except (TypeError, ValueError): + return default + + return { + "enabled": _b("enabled", False), + "smtp_host": _s("smtp_host"), + "smtp_port": _i("smtp_port", 587), + "smtp_user": _s("smtp_user"), + "smtp_password_set": bool(_s("smtp_password")), + "smtp_use_tls": _b("smtp_use_tls", True), + "smtp_use_ssl": _b("smtp_use_ssl", False), + "from_addr": _s("from_addr"), + "from_name": _s("from_name") or "Palladium Z1 Monitor", + "to_addrs": [a.strip() for a in _s("to_addrs").split(",") if a.strip()], + "send_hour": _i("send_hour", 9), + "send_minute": _i("send_minute", 0), + "monitor_base_url": _s("monitor_base_url") or "http://127.0.0.1:5100", + } + + +def save_config(data: dict) -> int: + """保存配置到数据库。返回保存的键数。""" + Model = _get_email_config_model() + if not Model: + raise RuntimeError("EmailConfig 模型不可用") + + to_addrs = data.get("to_addrs") + if isinstance(to_addrs, list): + to_addrs_str = ",".join(str(x) for x in to_addrs if x) + else: + to_addrs_str = str(to_addrs or "") + + save_fields = { + "enabled": str(data.get("enabled", False)).lower(), + "smtp_host": str(data.get("smtp_host", "")).strip(), + "smtp_port": str(data.get("smtp_port", 587)), + "smtp_user": str(data.get("smtp_user", "")).strip(), + "smtp_password": str(data.get("smtp_password", "")), + "smtp_use_tls": str(data.get("smtp_use_tls", True)).lower(), + "smtp_use_ssl": str(data.get("smtp_use_ssl", False)).lower(), + "from_addr": str(data.get("from_addr", "")).strip(), + "from_name": str(data.get("from_name", "Palladium Z1 Monitor")).strip(), + "to_addrs": to_addrs_str.strip(), + "send_hour": str(data.get("send_hour", 9)), + "send_minute": str(data.get("send_minute", 0)), + "monitor_base_url": str(data.get("monitor_base_url", "http://127.0.0.1:5100")).strip(), + } + + count = 0 + with app.app_context(): + for key, value in save_fields.items(): + row = Model.query.filter_by(key=key).first() + if row: + row.value = value + else: + db.session.add(Model(key=key, value=value)) + count += 1 + db.session.commit() + return count + + +def reset_config() -> bool: + """清除数据库配置, 回退到环境变量。""" + Model = _get_email_config_model() + if not Model: + return False + with app.app_context(): + Model.query.delete() + db.session.commit() + return True + + +def get_config() -> dict: + """读取邮件配置。优先使用数据库存储的配置, 回退到环境变量。""" + db_raw = _read_db_config() + if db_raw is not None: + return _build_config_from_dict(db_raw) + return _build_config_from_env() + + +def get_config_full() -> dict: + """读取完整配置 (含密码原始值)。优先 DB, 回退到环境变量。""" + cfg = get_config() + db_raw = _read_db_config() + if db_raw is not None: + cfg["smtp_password"] = db_raw.get("smtp_password", "").strip() + else: + cfg["smtp_password"] = os.environ.get("SMTP_PASSWORD", "").strip() + return cfg + + def config_errors(cfg: dict) -> List[str]: """检查配置是否完整可发送。返回缺失/错误项的列表 (空=OK)。""" errs = [] @@ -440,12 +568,16 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str msg.attach(MIMEText(text, "plain", "utf-8")) msg.attach(MIMEText(html, "html", "utf-8")) + password = cfg.get("smtp_password", "") + if not password: + password = os.environ.get("SMTP_PASSWORD", "") + try: if cfg["smtp_use_ssl"]: # SMTP_SSL (port 465) ctx = ssl.create_default_context() with smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], context=ctx, timeout=30) as s: - s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", "")) + s.login(cfg["smtp_user"], password) s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string()) else: # STARTTLS (port 587) 或 明文 (port 25) @@ -454,7 +586,7 @@ def _send_smtp(cfg: dict, subject: str, html: str, text: str) -> Tuple[bool, str if cfg["smtp_use_tls"]: s.starttls() s.ehlo() - s.login(cfg["smtp_user"], os.environ.get("SMTP_PASSWORD", "")) + s.login(cfg["smtp_user"], password) s.sendmail(cfg["from_addr"], cfg["to_addrs"], msg.as_string()) return (True, "") except smtplib.SMTPAuthenticationError as e: @@ -474,7 +606,7 @@ def send_report(cfg: Optional[Dict] = None) -> dict: 收集数据 → 渲染 → 发送。 返回: {"ok": bool, "subject": str, "recipients": [...], "error": str, "preview_text": str} """ - cfg = cfg or get_config() + cfg = cfg or get_config_full() if not cfg["enabled"]: return {"ok": False, "error": "邮件功能未启用 (MAIL_ENABLED 未设 true)", "subject": "", "recipients": []} errs = config_errors(cfg) diff --git a/static/style.css b/static/style.css index cb53b73..3ff4502 100644 --- a/static/style.css +++ b/static/style.css @@ -690,6 +690,228 @@ body { padding: 16px; } +/* ─── 邮件配置页面 ─── */ +.page-header { + margin-bottom: 20px; +} +.page-header h1 { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); +} +.page-subtitle { + color: var(--text-secondary); + font-size: 13px; + margin-top: 4px; +} + +/* 状态卡片 */ +.config-status-card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; + transition: border-color 0.2s; +} +.config-status-card.status-ok { border-color: var(--green); background: rgba(63,185,80,0.06); } +.config-status-card.status-error { border-color: var(--red); background: rgba(248,81,73,0.06); } +.config-status-card.status-warning { border-color: var(--orange); background: rgba(210,153,34,0.06); } +.status-icon { font-size: 24px; flex-shrink: 0; } +.status-title { font-size: 15px; font-weight: 600; color: var(--text-primary); } +.status-detail { font-size: 13px; color: var(--text-secondary); margin-top: 2px; } + +/* 配置卡片 */ +.config-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px 24px; + margin-bottom: 16px; +} +.config-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} +.config-card-header h2 { font-size: 16px; font-weight: 700; } +.config-source-badge { + font-size: 11px; + color: var(--accent); + background: rgba(88,166,255,0.1); + padding: 2px 8px; + border-radius: 4px; +} + +/* 表单通用 */ +.form-group { margin-bottom: 14px; } +.form-group label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 5px; + font-weight: 500; +} +.form-group input[type="text"], +.form-group input[type="email"], +.form-group input[type="url"], +.form-group input[type="password"], +.form-group input[type="number"], +.form-group textarea { + width: 100%; + background: var(--bg-primary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + font-family: inherit; + transition: border-color 0.15s; +} +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--accent); +} +.form-group textarea { resize: vertical; line-height: 1.5; } +.label-hint { color: var(--text-muted); font-weight: 400; font-size: 11px; } + +/* 小字段 (行内并排) */ +.form-group-small { margin-bottom: 14px; } + +/* 表单分区 */ +.form-section { + padding: 16px 0; + border-top: 1px solid var(--border); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.form-section:first-of-type { border-top: none; padding-top: 0; } + +/* 全宽字段 */ +.form-group.full-width { grid-column: 1 / -1; } + +/* 启用开关 */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 16px; +} +.toggle-title { font-size: 14px; font-weight: 600; color: var(--text-primary); } +.toggle-desc { font-size: 12px; color: var(--text-secondary); margin-top: 2px; } +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + cursor: pointer; +} +.toggle-switch input { display: none; } +.toggle-slider { + position: absolute; + inset: 0; + background: var(--border-light); + border-radius: 12px; + transition: 0.2s; +} +.toggle-slider:before { + content: ""; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: 0.2s; +} +.toggle-switch input:checked + .toggle-slider { background: var(--accent); } +.toggle-switch input:checked + .toggle-slider:before { transform: translateX(20px); } + +/* 密码输入 */ +.password-input-wrap { position: relative; } +.password-input-wrap input { padding-right: 36px; } +.password-toggle { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px; + color: var(--text-secondary); +} + +/* 复选框组 */ +.checkbox-group { display: flex; gap: 16px; padding-top: 4px; } +.checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; +} +.checkbox-label input[type="checkbox"] { + accent-color: var(--accent); + width: 15px; + height: 15px; +} + +/* 操作按钮 */ +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding-top: 18px; + border-top: 1px solid var(--border); + margin-top: 6px; +} +.form-actions .btn { white-space: nowrap; } +.btn-outline { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} +.btn-outline:hover { border-color: var(--text-secondary); color: var(--text-primary); } +.form-actions .btn:disabled { opacity: 0.5; cursor: not-allowed; } + +/* 信息卡片 */ +.info-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + padding: 14px 18px; +} +.info-card h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); +} +.info-card ul { margin: 0; padding-left: 18px; } +.info-card li { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 4px; + line-height: 1.5; +} + /* ─── Responsive ─── */ @media (max-width: 768px) { .topnav { padding: 0 12px; } @@ -698,4 +920,8 @@ body { .refresh-controls { margin-left: 0; } .boards-grid { grid-template-columns: 1fr; } .metrics-grid { grid-template-columns: 1fr; } + .form-section { grid-template-columns: 1fr; } + .toggle-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .form-actions { flex-direction: column; } + .form-actions .btn { width: 100%; text-align: center; } } diff --git a/templates/base.html b/templates/base.html index e45419e..c1d7e9c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -16,6 +16,7 @@ diff --git a/templates/email_config.html b/templates/email_config.html new file mode 100644 index 0000000..2688c1f --- /dev/null +++ b/templates/email_config.html @@ -0,0 +1,330 @@ +{% extends "base.html" %} +{% block title %}邮件配置 — Palladium Z1 Monitor{% endblock %} + +{% block content %} + + + + + +
+
+
+
加载中...
+
正在获取当前配置...
+
+
+ + +
+
+

⚙️ SMTP 配置

+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + 👁 预览报告 + +
+
+ + +
+

ℹ️ 说明

+
    +
  • 配置保存到数据库后立即可用,邮件调度器会自动读取新配置。
  • +
  • 如果数据库和环境中都未配置,回退到环境变量 (SMTP_HOST, SMTP_USER 等)。
  • +
  • 「恢复环境变量默认」会清除数据库中的配置,回退到环境变量。
  • +
  • 手动发送测试邮件需先确保配置完整(无错误提示)。
  • +
+
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file