消息提示中心
一个用 Python 写的小型桌面通知面板。它把几种常见、以及一些偏风格化的消息样式放在同一个窗口里,方便调标题、正文、图标和显示时间后直接预览。
下载:https://serendisand.lanzouu.com/iWCSw3zralah
密码:a15w
程序界面截图放在项目附件中,可与本说明一起查看。
能做什么
- 发送 Windows Toast 通知:会从系统右下角出现,并保留在 Windows 通知中心。
- 显示 Windows 原生对话框:传统的
MessageBox,带确定按钮。
- 预览多种自绘消息样式:侧边吐司、灵动岛、ClickGUI、顶部横幅、聊天气泡、终端、信笺、胶囊、二次元角色卡片和聚光舞台等。
- 设置消息标题、正文、信息等级、停留时间与显示位置。
- 为自绘通知添加自己的小图标。
- 开启调试模式后,将 Windows 通知和 PowerShell 的执行信息写入日志,方便排查问题。
运行环境
需要 Python 3.10 或更高版本。程序仅使用标准库,不需要 pip install。
python main.py
在 Windows 下建议用命令提示符或 PowerShell 从项目目录启动。Windows Toast 通知会调用系统自带的 PowerShell 和通知 API。
使用方式
- 从左侧选择一个模板。
- 填写标题和消息内容。
- 根据需要选择信息、成功、警告或错误等级。
- 对悬浮模板,可选择显示位置和停留时长;部分模板会固定在符合其样式的位置,例如灵动岛固定在屏幕上方居中。
- 点击 测试发送 查看效果。
Windows 通知
“Windows 通知”会调用 Windows Toast API,通常从右下角出现,并可在通知中心查看。若没有看到横幅,请检查 Windows 的“请勿打扰 / 专注助手”设置;通知也可能被系统静默放入通知中心。
自定义图标
自绘消息模板支持添加图标。当前支持以下格式:
图标尺寸需要在 16×16 到 256×256 像素之间。程序会在显示时按比例缩小较大的图标。Windows 系统 Toast 和 Windows 原生对话框受系统 API 限制,仍使用系统提供的图标。
调试日志
底部勾选 调试模式 后再发送消息,程序会在项目目录生成或追加:
notification_debug.log
日志会包含所选模板、Windows Toast 的 PowerShell 返回信息,以及 Windows API 调用结果。遇到 Windows 通知没有显示的情况,可以把相关日志内容贴出来排查。
说明
这个项目适合做本地通知、界面效果预览或桌面小工具原型。请不要用它伪造系统安全、登录、支付等界面,或用来误导他人。
源码
"""本地消息提示中心:使用 Tkinter 和 Windows MessageBoxW 的通知模板面板。"""
from __future__ import annotations
import base64
import json
import os
import platform
import subprocess
import sys
import time
import tkinter as tk
import traceback
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Callable
MAX_ICON_SIZE = 256
MIN_ICON_SIZE = 16
SUPPORTED_ICON_TYPES = {".png", ".gif", ".ppm", ".pgm"}
LOG_FILE = Path(__file__).with_name("notification_debug.log")
TOAST_SCRIPT_NAME = "windows_toast.ps1"
def resource_path(filename: str) -> Path:
root = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
return root / filename
TOAST_SCRIPT_FILE = resource_path(TOAST_SCRIPT_NAME)
PALETTE = {
"bg": "#eef8f5",
"panel": "#ffffff",
"panel_hover": "#e2f4ee",
"surface": "#f5fbf8",
"border": "#b8ddd2",
"text": "#24463e",
"muted": "#6b8d83",
"accent": "#58bda5",
"success": "#56bd91",
"warning": "#e6b75c",
"danger": "#df7885",
"info": "#62aede",
"cream": "#fffdf5",
"ink": "#315d53",
}
LEVELS = {
"信息": ("i", PALETTE["info"]),
"成功": ("✓", PALETTE["success"]),
"警告": ("!", PALETTE["warning"]),
"错误": ("×", PALETTE["danger"]),
}
@dataclass(frozen=True)
class Template:
key: str
name: str
subtitle: str
symbol: str
sender: Callable[[str, str], None]
class FloatingNotice(tk.Toplevel):
def __init__(
self,
app: "MessageCenter",
title: str,
content: str,
*,
style: str,
duration: float,
position: str,
icon: tk.PhotoImage | None,
) -> None:
super().__init__(app)
self.app = app
self.style = style
self.duration = duration
self.position = position
self.icon = icon
self.width = 380
self.height = 126
self._closing = False
self._after_ids: set[str] = set()
self.withdraw()
self.overrideredirect(True)
self.attributes("-topmost", True)
self.attributes("-alpha", 0.0)
self.configure(bg=PALETTE["bg"])
symbol, color = LEVELS[app.level_var.get()]
builders = {
"toast": self._build_toast,
"island": self._build_island,
"clickgui": self._build_clickgui,
"banner": self._build_banner,
"bubble": self._build_bubble,
"terminal": self._build_terminal,
"letter": self._build_letter,
"pill": self._build_pill,
"chibi": self._build_chibi,
"spotlight": self._build_spotlight,
}
builders[style](title, content, symbol, color)
self.bind("<Button-1>", lambda _event: self.close())
self._schedule_idle(self.animate_in)
def _schedule(self, delay: int, callback: Callable[[], None]) -> None:
after_id = ""
def run() -> None:
self._after_ids.discard(after_id)
if self.winfo_exists():
callback()
after_id = self.after(delay, run)
self._after_ids.add(after_id)
def _schedule_idle(self, callback: Callable[[], None]) -> None:
after_id = ""
def run() -> None:
self._after_ids.discard(after_id)
if self.winfo_exists():
callback()
after_id = self.after_idle(run)
self._after_ids.add(after_id)
def _icon_or_symbol(self, parent: tk.Misc, symbol: str, color: str, bg: str, size: int = 36) -> None:
if self.icon is not None:
tk.Label(parent, image=self.icon, bg=bg, width=size, height=size).pack(side="left", padx=(14, 10), pady=14)
else:
tk.Label(parent, text=symbol, font=("TkDefaultFont", 16, "bold"), fg=bg, bg=color, width=3).pack(side="left", padx=(14, 10), pady=14)
def _body(self, parent: tk.Misc, title: str, content: str, *, bg: str, title_fg: str, text_fg: str, wrap: int) -> None:
body = tk.Frame(parent, bg=bg)
body.pack(side="left", fill="both", expand=True, pady=14)
tk.Label(body, text=title, font=("Segoe UI", 11, "bold"), fg=title_fg, bg=bg, anchor="w").pack(fill="x")
tk.Label(body, text=content, font=("Segoe UI", 9), fg=text_fg, bg=bg, anchor="w", justify="left", wraplength=wrap).pack(fill="both", expand=True, pady=(4, 0))
def _base_card(self, bg: str = PALETTE["surface"], border: str = PALETTE["border"]) -> tk.Frame:
card = tk.Frame(self, bg=bg, highlightbackground=border, highlightthickness=1)
card.pack(fill="both", expand=True)
return card
def _build_toast(self, title: str, content: str, symbol: str, color: str) -> None:
card = self._base_card()
tk.Frame(card, bg=color, width=5).pack(side="left", fill="y")
self._icon_or_symbol(card, symbol, color, PALETTE["surface"])
self._body(card, title, content, bg=PALETTE["surface"], title_fg=PALETTE["text"], text_fg=PALETTE["muted"], wrap=245)
def _build_island(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 430, 98
card = self._base_card("#0a0810", "#493c5f")
self._icon_or_symbol(card, symbol, color, "#0a0810")
self._body(card, title, content, bg="#0a0810", title_fg=PALETTE["text"], text_fg="#c8bbd4", wrap=290)
tk.Label(card, text="●", font=("Segoe UI", 8), fg=color, bg="#0a0810").pack(side="right", padx=20)
def _build_clickgui(self, title: str, content: str, symbol: str, color: str) -> None:
card = self._base_card("#20192c", "#80689d")
header = tk.Frame(card, bg="#4b385e", height=34)
header.pack(fill="x")
header.pack_propagate(False)
tk.Label(header, text=f" {symbol} {title}", font=("Consolas", 10, "bold"), fg=color, bg="#4b385e", anchor="w").pack(side="left", fill="both", expand=True)
tk.Label(card, text=content, font=("Consolas", 9), fg="#f2e9f6", bg="#20192c", anchor="w", justify="left", wraplength=340).pack(fill="both", expand=True, padx=18, pady=14)
def _build_banner(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = max(560, self.winfo_screenwidth() - 80), 72
card = tk.Frame(self, bg=color)
card.pack(fill="both", expand=True)
self._icon_or_symbol(card, symbol, PALETTE["ink"], color)
tk.Label(card, text=title, font=("Segoe UI", 11, "bold"), fg=PALETTE["ink"], bg=color).pack(side="left")
tk.Label(card, text=f" · {content}", font=("Segoe UI", 10), fg="#604766", bg=color, anchor="w").pack(side="left", fill="x", expand=True)
def _build_bubble(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 325, 116
card = self._base_card(PALETTE["cream"], color)
self._icon_or_symbol(card, symbol, color, PALETTE["cream"])
self._body(card, title, content, bg=PALETTE["cream"], title_fg=PALETTE["ink"], text_fg="#806783", wrap=210)
def _build_terminal(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 425, 126
card = self._base_card("#191421", "#8c72aa")
tk.Label(card, text=f" [{symbol}] NOTICE", font=("Consolas", 10, "bold"), fg=color, bg="#30243f", anchor="w").pack(fill="x", ipady=8)
tk.Label(card, text=f"> {title}\n {content}", font=("Consolas", 10), fg="#f3dced", bg="#191421", anchor="w", justify="left", wraplength=380).pack(fill="both", expand=True, padx=16, pady=12)
def _build_letter(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 390, 150
card = self._base_card("#fff9ef", "#d8b97c")
tk.Label(card, text="✉", font=("TkDefaultFont", 24), fg="#c07a85", bg="#fff9ef").pack(anchor="w", padx=20, pady=(14, 0))
tk.Label(card, text=title, font=("Segoe UI", 12, "bold"), fg="#553944", bg="#fff9ef", anchor="w").pack(fill="x", padx=20)
tk.Label(card, text=content, font=("Segoe UI", 9), fg="#82636e", bg="#fff9ef", anchor="w", justify="left", wraplength=340).pack(fill="both", expand=True, padx=20, pady=(5, 14))
def _build_pill(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 460, 76
card = self._base_card("#f9eafa", "#ed9ec6")
self._icon_or_symbol(card, symbol, color, "#f9eafa")
tk.Label(card, text=f"{title} ", font=("Segoe UI", 10, "bold"), fg="#653e61", bg="#f9eafa").pack(side="left")
tk.Label(card, text=content, font=("Segoe UI", 9), fg="#926e90", bg="#f9eafa", anchor="w", wraplength=220).pack(side="left", fill="x", expand=True)
def _build_chibi(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 365, 135
card = self._base_card("#f2dff1", "#ff9bc7")
portrait = tk.Label(card, text="✦\n◕‿◕", font=("TkDefaultFont", 13, "bold"), fg="#ffffff", bg="#ee86b7", width=6)
portrait.pack(side="left", fill="y")
self._body(card, title, content, bg="#f2dff1", title_fg="#633f65", text_fg="#815f83", wrap=235)
def _build_spotlight(self, title: str, content: str, symbol: str, color: str) -> None:
self.width, self.height = 460, 142
card = self._base_card("#302041", color)
tk.Label(card, text=symbol, font=("TkDefaultFont", 26, "bold"), fg=color, bg="#302041").pack(pady=(14, 0))
tk.Label(card, text=title, font=("Segoe UI", 12, "bold"), fg=PALETTE["text"], bg="#302041").pack()
tk.Label(card, text=content, font=("Segoe UI", 9), fg="#d5c7e0", bg="#302041", wraplength=400, justify="center").pack(padx=18, pady=(3, 12))
def _target_geometry(self) -> tuple[int, int, int, int]:
self.update_idletasks()
sw, sh = self.winfo_screenwidth(), self.winfo_screenheight()
margin = 22
if self.style in {"island", "banner", "spotlight"}:
target_x = (sw - self.width) // 2
target_y = 8 if self.style != "spotlight" else max(30, (sh - self.height) // 3)
return target_x, target_y, target_x, -self.height - 12
if self.style in {"bubble", "letter", "pill"}:
return sw - self.width - margin, sh - self.height - 76, sw + 20, sh - self.height - 76
if self.position == "左上":
return margin, margin, -self.width - 20, margin
if self.position == "右上":
return sw - self.width - margin, margin, sw + 20, margin
if self.position == "左下":
y = sh - self.height - 76
return margin, y, -self.width - 20, y
y = sh - self.height - 76
return sw - self.width - margin, y, sw + 20, y
def _exit_geometry(self) -> tuple[int, int]:
target_x, target_y, _, _ = self._target_geometry()
if self.style in {"island", "banner"}:
return target_x, -self.height - 12
if self.style == "clickgui":
return target_x, min(self.winfo_screenheight() - 76, target_y + self.height + 28)
if self.style == "spotlight":
return target_x, self.winfo_screenheight() + 20
if self.position.startswith("左") and self.style not in {"bubble", "letter", "pill"}:
return -self.width - 20, target_y
return self.winfo_screenwidth() + 20, target_y
def animate_in(self) -> None:
if self._closing:
return
target_x, target_y, start_x, start_y = self._target_geometry()
self.geometry(f"{self.width}x{self.height}+{start_x}+{start_y}")
self.deiconify()
steps = 16
def step(index: int = 0) -> None:
if self._closing:
return
progress = min(1, index / steps)
eased = 1 - (1 - progress) ** 3
x = int(start_x + (target_x - start_x) * eased)
y = int(start_y + (target_y - start_y) * eased)
self.geometry(f"{self.width}x{self.height}+{x}+{y}")
self.attributes("-alpha", min(0.98, 0.25 + 0.73 * eased))
if index < steps:
self._schedule(12, lambda: step(index + 1))
else:
self._schedule(int(self.duration * 1000), self.close)
step()
def close(self) -> None:
if self._closing or not self.winfo_exists():
return
self._closing = True
for after_id in self._after_ids.copy():
self.after_cancel(after_id)
self._after_ids.clear()
start_x, start_y = self.winfo_x(), self.winfo_y()
end_x, end_y = self._exit_geometry()
steps = 14
def animate_out(index: int = 0) -> None:
if not self.winfo_exists():
return
progress = min(1, index / steps)
eased = 1 - (1 - progress) ** 3
x = int(start_x + (end_x - start_x) * eased)
y = int(start_y + (end_y - start_y) * eased)
self.geometry(f"{self.width}x{self.height}+{x}+{y}")
self.attributes("-alpha", max(0.0, 0.98 * (1 - progress)))
if index < steps:
self._schedule(14, lambda: animate_out(index + 1))
else:
self.destroy()
animate_out()
class MessageCenter(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("消息提示中心")
self.geometry("1080x720")
self.minsize(920, 620)
self.configure(bg=PALETTE["bg"])
self.option_add("*Font", "TkDefaultFont")
self.style = ttk.Style(self)
self.style.theme_use("clam")
self._configure_ttk()
self.selected_key = "native"
self.level_var = tk.StringVar(value="信息")
self.duration_var = tk.DoubleVar(value=4)
self.position_var = tk.StringVar(value="右下")
self.title_var = tk.StringVar(value="操作完成")
self.status_var = tk.StringVar(value="准备就绪")
self.debug_var = tk.BooleanVar(value=False)
self.icon_path_var = tk.StringVar(value="未选择图标")
self.icon_image: tk.PhotoImage | None = None
self.template_buttons: dict[str, tk.Frame] = {}
self.templates = [
Template("native", "Windows 通知", "右下角系统 Toast 通知", "▣", self.send_windows_toast),
Template("toast", "侧边吐司", "简洁的桌面通知卡片", "◐", self.send_toast),
Template("island", "灵动岛", "屏幕上方居中悬浮", "●", self.send_island),
Template("clickgui", "ClickGUI", "右侧滑入、向下收回", "▰", self.send_clickgui),
Template("banner", "全宽横幅", "从顶部展开的提醒", "═", self.send_banner),
Template("bubble", "聊天气泡", "明亮的聊天式提示", "✦", self.send_bubble),
Template("terminal", "终端窗口", "复古命令行样式", ">_", self.send_terminal),
Template("letter", "手写信笺", "暖色纸张与信件布局", "✉", self.send_letter),
Template("pill", "糖果胶囊", "轻盈的圆润提示条", "♥", self.send_pill),
Template("chibi", "二次元角色", "粉色角色卡片提示", "☆", self.send_chibi),
Template("spotlight", "聚光舞台", "中央强调式消息", "✧", self.send_spotlight),
]
self.template_by_key = {template.key: template for template in self.templates}
self._build_ui()
def _configure_ttk(self) -> None:
self.style.configure("Dark.TCombobox", fieldbackground=PALETTE["surface"], background=PALETTE["surface"], foreground=PALETTE["text"], arrowcolor=PALETTE["text"], bordercolor=PALETTE["border"])
self.style.map("Dark.TCombobox", fieldbackground=[("readonly", PALETTE["surface"])], selectbackground=[("readonly", PALETTE["surface"])], selectforeground=[("readonly", PALETTE["text"])])
self.style.configure("Horizontal.TScale", background=PALETTE["panel"], troughcolor=PALETTE["border"], sliderthickness=15)
def _label(self, parent: tk.Misc, text: str | None = None, *, muted: bool = False, **kwargs: object) -> tk.Label:
options: dict[str, object] = {"fg": PALETTE["muted"] if muted else PALETTE["text"], "bg": parent.cget("bg"), **kwargs}
if text is not None:
options["text"] = text
return tk.Label(parent, **options)
def _build_ui(self) -> None:
sidebar = tk.Frame(self, bg=PALETTE["panel"], width=310)
sidebar.pack(side="left", fill="y")
sidebar.pack_propagate(False)
self._build_sidebar(sidebar)
main = tk.Frame(self, bg=PALETTE["bg"])
main.pack(side="left", fill="both", expand=True)
self._build_main(main)
def _build_sidebar(self, parent: tk.Frame) -> None:
brand = tk.Frame(parent, bg=PALETTE["panel"])
brand.pack(fill="x", padx=24, pady=(24, 16))
tk.Label(brand, text="✦", font=("TkDefaultFont", 24), fg=PALETTE["accent"], bg=PALETTE["panel"]).pack(side="left", padx=(0, 10))
text_box = tk.Frame(brand, bg=PALETTE["panel"])
text_box.pack(side="left")
self._label(text_box, "消息提示中心", font=("Segoe UI", 14, "bold")).pack(anchor="w")
self._label(text_box, "NOTIFICATION STUDIO", muted=True, font=("Segoe UI", 8, "bold")).pack(anchor="w")
self._label(parent, "提示模板 · 向下滚动查看更多", muted=True, font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=25, pady=(0, 8))
canvas = tk.Canvas(parent, bg=PALETTE["panel"], highlightthickness=0, borderwidth=0)
self.template_canvas = canvas
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
cards = tk.Frame(canvas, bg=PALETTE["panel"])
cards.bind("<Configure>", lambda _event: canvas.configure(scrollregion=canvas.bbox("all")))
canvas_window = canvas.create_window((0, 0), window=cards, anchor="nw")
canvas.bind("<Configure>", lambda event: canvas.itemconfigure(canvas_window, width=event.width))
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True, padx=(8, 0), pady=(0, 18))
scrollbar.pack(side="right", fill="y", pady=(0, 18))
canvas.bind("<Enter>", self._enable_template_scroll)
canvas.bind("<Leave>", self._disable_template_scroll)
cards.bind("<Enter>", self._enable_template_scroll)
cards.bind("<Leave>", self._disable_template_scroll)
for template in self.templates:
self._make_template_card(cards, template)
def _enable_template_scroll(self, _event: tk.Event[tk.Misc]) -> None:
self.bind_all("<MouseWheel>", self._scroll_template_list)
def _disable_template_scroll(self, _event: tk.Event[tk.Misc]) -> None:
self.unbind_all("<MouseWheel>")
def _scroll_template_list(self, event: tk.Event[tk.Misc]) -> None:
self.template_canvas.yview_scroll(-int(event.delta / 120), "units")
def _make_template_card(self, parent: tk.Frame, template: Template) -> None:
card = tk.Frame(parent, bg=PALETTE["panel"], cursor="hand2")
card.pack(fill="x", padx=8, pady=3)
icon = tk.Label(card, text=template.symbol, font=("TkDefaultFont", 15, "bold"), fg=PALETTE["accent"], bg=PALETTE["panel"], width=3)
icon.pack(side="left", pady=9)
body = tk.Frame(card, bg=PALETTE["panel"])
body.pack(side="left", fill="x", expand=True, pady=8)
self._label(body, template.name, font=("Segoe UI", 10, "bold")).pack(anchor="w")
self._label(body, template.subtitle, muted=True, font=("Segoe UI", 8)).pack(anchor="w", pady=(2, 0))
self.template_buttons[template.key] = card
for widget in (card, icon, body, *body.winfo_children()):
widget.bind("<Button-1>", lambda _event, key=template.key: self.select_template(key))
self._paint_template_card(template.key)
def _paint_template_card(self, key: str) -> None:
card = self.template_buttons[key]
active = key == self.selected_key
bg = PALETTE["panel_hover"] if active else PALETTE["panel"]
card.configure(bg=bg, highlightbackground=PALETTE["accent"] if active else bg, highlightthickness=1 if active else 0)
self._paint_descendants(card, bg)
def _paint_descendants(self, widget: tk.Widget, bg: str) -> None:
try:
widget.configure(bg=bg)
except tk.TclError:
pass
for child in widget.winfo_children():
self._paint_descendants(child, bg)
def select_template(self, key: str) -> None:
if key == self.selected_key:
return
previous = self.selected_key
self.selected_key = key
self._paint_template_card(previous)
self._paint_template_card(key)
self.status_var.set(f"已选择:{self.template_by_key[key].name}")
def _build_main(self, parent: tk.Frame) -> None:
header = tk.Frame(parent, bg=PALETTE["bg"])
header.pack(fill="x", padx=38, pady=(28, 8))
self._label(header, "创建一条消息", font=("Segoe UI", 22, "bold")).pack(anchor="w")
self._label(header, "选择视觉模板、内容与可选图标后,立即发送测试。", muted=True).pack(anchor="w", pady=(3, 0))
form = tk.Frame(parent, bg=PALETTE["panel"], highlightbackground=PALETTE["border"], highlightthickness=1)
form.pack(fill="both", expand=True, padx=38, pady=18)
form.columnconfigure(0, weight=1)
form.columnconfigure(1, weight=1)
self._build_content_fields(form)
self._build_icon_picker(form)
self._build_options(form)
self._build_actions(parent)
def _build_content_fields(self, parent: tk.Frame) -> None:
self._label(parent, "标题", muted=True, font=("Segoe UI", 9, "bold")).grid(row=0, column=0, sticky="w", padx=25, pady=(20, 7))
title_entry = tk.Entry(parent, textvariable=self.title_var, bg=PALETTE["surface"], fg=PALETTE["text"], insertbackground=PALETTE["text"], relief="flat", highlightthickness=1, highlightbackground=PALETTE["border"], highlightcolor=PALETTE["accent"], font=("Segoe UI", 11), borderwidth=10)
title_entry.grid(row=1, column=0, columnspan=2, sticky="ew", padx=25)
self._label(parent, "消息内容", muted=True, font=("Segoe UI", 9, "bold")).grid(row=2, column=0, sticky="w", padx=25, pady=(18, 7))
self.content_text = tk.Text(parent, height=5, bg=PALETTE["surface"], fg=PALETTE["text"], insertbackground=PALETTE["text"], relief="flat", highlightthickness=1, highlightbackground=PALETTE["border"], highlightcolor=PALETTE["accent"], font=("Segoe UI", 10), padx=10, pady=9, wrap="word")
self.content_text.insert("1.0", "你的消息已发送成功。")
self.content_text.grid(row=3, column=0, columnspan=2, sticky="nsew", padx=25)
def _build_icon_picker(self, parent: tk.Frame) -> None:
box = tk.Frame(parent, bg=PALETTE["panel"])
box.grid(row=4, column=0, columnspan=2, sticky="ew", padx=25, pady=(18, 0))
self._label(box, "自定义图标(悬浮模板)", muted=True, font=("Segoe UI", 9, "bold")).pack(anchor="w")
row = tk.Frame(box, bg=PALETTE["panel"])
row.pack(fill="x", pady=(7, 0))
self.icon_preview = tk.Label(row, text="无", font=("Segoe UI", 8), fg=PALETTE["muted"], bg=PALETTE["surface"], width=5, height=2)
self.icon_preview.pack(side="left")
self._label(row, None, textvariable=self.icon_path_var, muted=True, font=("Segoe UI", 9), anchor="w").pack(side="left", fill="x", expand=True, padx=10)
tk.Button(row, text="选择图标", command=self.choose_icon, bg=PALETTE["surface"], fg=PALETTE["text"], activebackground=PALETTE["panel_hover"], activeforeground=PALETTE["text"], relief="flat", cursor="hand2", padx=12, pady=7).pack(side="right")
tk.Button(row, text="清除", command=self.clear_icon, bg=PALETTE["panel"], fg=PALETTE["muted"], activebackground=PALETTE["panel_hover"], relief="flat", cursor="hand2", padx=8, pady=7).pack(side="right", padx=5)
self._label(box, "仅支持 PNG / GIF / PPM / PGM,尺寸必须介于 16×16 与 256×256 像素。Windows 系统消息使用系统图标。", muted=True, font=("Segoe UI", 8), wraplength=600, justify="left").pack(anchor="w", pady=(5, 0))
def _build_options(self, parent: tk.Frame) -> None:
box = tk.Frame(parent, bg=PALETTE["panel"])
box.grid(row=5, column=0, columnspan=2, sticky="ew", padx=25, pady=18)
for index in range(3):
box.columnconfigure(index, weight=1)
for index, text in enumerate(("消息类型", "显示位置", "停留时间")):
self._label(box, text, muted=True, font=("Segoe UI", 9, "bold")).grid(row=0, column=index, sticky="w", padx=0 if index == 0 else 12)
ttk.Combobox(box, textvariable=self.level_var, values=list(LEVELS), state="readonly", style="Dark.TCombobox").grid(row=1, column=0, sticky="ew", pady=(8, 0))
ttk.Combobox(box, textvariable=self.position_var, values=["右下", "右上", "左下", "左上"], state="readonly", style="Dark.TCombobox").grid(row=1, column=1, sticky="ew", padx=12, pady=(8, 0))
duration = tk.Frame(box, bg=PALETTE["panel"])
duration.grid(row=1, column=2, sticky="ew", padx=12, pady=(6, 0))
ttk.Scale(duration, from_=1, to=10, variable=self.duration_var, orient="horizontal", style="Horizontal.TScale").pack(side="left", fill="x", expand=True)
value = self._label(duration, "4 秒", font=("Segoe UI", 9, "bold"), width=5)
value.pack(side="right", padx=(8, 0))
self.duration_var.trace_add("write", lambda *_: value.configure(text=f"{self.duration_var.get():.0f} 秒"))
def _build_actions(self, parent: tk.Frame) -> None:
action = tk.Frame(parent, bg=PALETTE["bg"])
action.pack(fill="x", padx=38, pady=(0, 22))
self._label(action, None, textvariable=self.status_var, muted=True, font=("Segoe UI", 9)).pack(side="left", pady=12)
controls = tk.Frame(action, bg=PALETTE["bg"])
controls.pack(side="right", fill="x")
tk.Checkbutton(controls, text="调试模式", variable=self.debug_var, command=self._on_debug_toggle, bg=PALETTE["bg"], fg=PALETTE["muted"], activebackground=PALETTE["bg"], activeforeground=PALETTE["text"], selectcolor=PALETTE["surface"], highlightthickness=0, font=("Segoe UI", 9), cursor="hand2").pack(side="left", padx=(0, 10))
self.test_button = tk.Button(controls, text="◉ 测试发送", command=self.send_selected, bg=PALETTE["accent"], fg="white", activebackground="#419d88", activeforeground="white", relief="flat", cursor="hand2", font=("Segoe UI", 10, "bold"), padx=14, pady=11)
self.test_button.pack(side="right", fill="x", expand=True)
self.reset_button = tk.Button(controls, text="重置", command=self.reset_form, bg=PALETTE["surface"], fg=PALETTE["text"], activebackground=PALETTE["panel_hover"], activeforeground=PALETTE["text"], relief="flat", cursor="hand2", font=("Segoe UI", 10), padx=18, pady=11)
self.reset_button.pack(side="right", padx=10)
parent.bind("<Configure>", self._resize_action_buttons, add="+")
def _resize_action_buttons(self, event: tk.Event[tk.Misc]) -> None:
compact = event.width < 570
self.test_button.configure(text="发送" if compact else "◉ 测试发送", padx=8 if compact else 14)
self.reset_button.configure(text="↺" if compact else "重置", padx=10 if compact else 18)
def _on_debug_toggle(self) -> None:
state = "已开启" if self.debug_var.get() else "已关闭"
self.status_var.set(f"调试模式{state}")
self._log(f"调试模式{state}")
def _log(self, message: str, *, exception: BaseException | None = None) -> None:
if not self.debug_var.get():
return
lines = [f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {message}"]
if exception is not None:
lines.extend(traceback.format_exception(type(exception), exception, exception.__traceback__))
try:
with LOG_FILE.open("a", encoding="utf-8") as log_file:
log_file.write("\n".join(lines) + "\n")
except OSError:
pass
def choose_icon(self) -> None:
path = filedialog.askopenfilename(title="选择消息图标", filetypes=[("支持的图像", "*.png *.gif *.ppm *.pgm"), ("PNG", "*.png"), ("GIF", "*.gif")])
if not path:
return
extension = os.path.splitext(path)[1].lower()
if extension not in SUPPORTED_ICON_TYPES:
messagebox.showwarning("不支持的图标", "请使用 PNG、GIF、PPM 或 PGM 格式的图标。", parent=self)
return
try:
image = tk.PhotoImage(file=path)
width, height = image.width(), image.height()
except tk.TclError as exc:
messagebox.showerror("图标加载失败", f"无法读取该图像:{exc}", parent=self)
return
if not (MIN_ICON_SIZE <= width <= MAX_ICON_SIZE and MIN_ICON_SIZE <= height <= MAX_ICON_SIZE):
messagebox.showwarning("图标尺寸不符合要求", f"图标实际为 {width}×{height},允许范围是 {MIN_ICON_SIZE}×{MIN_ICON_SIZE} 至 {MAX_ICON_SIZE}×{MAX_ICON_SIZE} 像素。", parent=self)
return
scale = max(1, (max(width, height) + 47) // 48)
self.icon_image = image.subsample(scale, scale)
self.icon_preview.configure(image=self.icon_image, text="", width=48, height=48)
self.icon_path_var.set(os.path.basename(path))
self.status_var.set(f"已加载图标:{width}×{height}")
def clear_icon(self) -> None:
self.icon_image = None
self.icon_preview.configure(image="", text="无", width=5, height=2)
self.icon_path_var.set("未选择图标")
self.status_var.set("已清除图标")
def get_message(self) -> tuple[str, str]:
return self.title_var.get().strip() or "消息提示", self.content_text.get("1.0", "end").strip() or "这是一条消息。"
def send_selected(self) -> None:
title, content = self.get_message()
template = self.template_by_key[self.selected_key]
self._log(f"发送请求:模板={template.key},标题={title!r},内容长度={len(content)}")
try:
template.sender(title, content)
self.status_var.set(f"已发送:{template.name}({time.strftime('%H:%M:%S')})")
self._log(f"发送完成:模板={template.key}")
except Exception as exc:
self.status_var.set("发送失败")
self._log("发送异常", exception=exc)
messagebox.showerror("发送失败", str(exc), parent=self)
def _show_floating(self, title: str, content: str, style: str) -> None:
FloatingNotice(self, title, content, style=style, duration=self.duration_var.get(), position=self.position_var.get(), icon=self.icon_image)
def send_windows_toast(self, title: str, content: str) -> None:
if platform.system() != "Windows":
raise RuntimeError("Windows Toast 通知仅支持 Windows 系统。")
payload = base64.b64encode(json.dumps({"title": title, "content": content}, ensure_ascii=False).encode("utf-8")).decode("ascii")
command = [
"powershell.exe",
"-NoProfile",
"-File",
str(TOAST_SCRIPT_FILE),
"-Payload",
payload,
]
self._log(f"启动 Windows Toast:脚本={TOAST_SCRIPT_FILE},命令={command!r}")
try:
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", timeout=15, creationflags=subprocess.CREATE_NO_WINDOW)
except (OSError, subprocess.TimeoutExpired) as exc:
self._log("启动 Windows Toast 失败", exception=exc)
raise RuntimeError(f"无法启动 Windows Toast:{exc}") from exc
self._log(f"Windows Toast 退出:returncode={completed.returncode},stdout={completed.stdout!r},stderr={completed.stderr!r}")
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip() or "PowerShell 未返回具体错误。"
raise RuntimeError(f"Windows Toast 发送失败:{detail}")
def send_toast(self, title: str, content: str) -> None: self._show_floating(title, content, "toast")
def send_island(self, title: str, content: str) -> None: self._show_floating(title, content, "island")
def send_clickgui(self, title: str, content: str) -> None: self._show_floating(title, content, "clickgui")
def send_banner(self, title: str, content: str) -> None: self._show_floating(title, content, "banner")
def send_bubble(self, title: str, content: str) -> None: self._show_floating(title, content, "bubble")
def send_terminal(self, title: str, content: str) -> None: self._show_floating(title, content, "terminal")
def send_letter(self, title: str, content: str) -> None: self._show_floating(title, content, "letter")
def send_pill(self, title: str, content: str) -> None: self._show_floating(title, content, "pill")
def send_chibi(self, title: str, content: str) -> None: self._show_floating(title, content, "chibi")
def send_spotlight(self, title: str, content: str) -> None: self._show_floating(title, content, "spotlight")
def reset_form(self) -> None:
self.title_var.set("操作完成")
self.content_text.delete("1.0", "end")
self.content_text.insert("1.0", "你的消息已发送成功。")
self.level_var.set("信息")
self.duration_var.set(4)
self.position_var.set("右下")
self.clear_icon()
self.status_var.set("已重置表单")
if __name__ == "__main__":
MessageCenter().mainloop()