Windows 任务栏覆盖式小组件
构建紧凑的无边框置顶窗口,停靠在 Windows 任务栏上方,同时在系统托盘区域显示图标,用于展示实时数据。
技术栈
- Python 3.10+,tkinter(内置 GUI)
- pystray + Pillow(系统托盘图标)
- requests(HTTP API 请求)
- PyInstaller(打包为单个 exe)
pip install requests pystray Pillow pyinstaller
架构概览
main.py
├── 配置层 — JSON 持久化存储,代码标准化
├── 数据层 — 调用实时 API(新浪/腾讯/自定义)
├── GUI 层 — tkinter 无边框浮窗 + 右键菜单
│ ├── 主浮窗 — 置顶、可拖拽、显示实时数据
│ └── 设置弹窗 — Toplevel 模态对话框
├── 托盘图标层 — pystray 系统托盘图标(显示价格+右键退出)
└── 后台刷新循环 — threading.Thread 守护线程,定时刷新
构建步骤
1. 无边框浮窗
root = tk.Tk()
root.overrideredirect(True) # 无标题栏和边框
root.attributes("-topmost", True) # 始终置顶
root.attributes("-alpha", 0.92) # 轻微透明
root.configure(bg="#1A1A1A") # 深色背景
2. 定位到任务栏上方
使用 ctypes + Windows API 获取任务栏高度:
import ctypes
import ctypes.wintypes # 必须显式导入,否则 exe 打包后报错
def get_taskbar_height():
user32 = ctypes.windll.user32
work_area = ctypes.wintypes.RECT()
user32.SystemParametersInfoW(0x0030, 0, ctypes.byref(work_area), 0)
screen_h = user32.GetSystemMetrics(1)
return screen_h - (work_area.bottom - work_area.top)
def get_screen_size():
user32 = ctypes.windll.user32
return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
定位到屏幕右下角、任务栏上方:
sw, sh = get_screen_size()
taskbar_h = get_taskbar_height()
x = sw - win_w - 160 # 右偏移 160px,避开系统托盘图标区
y = sh - taskbar_h - win_h - 2
root.geometry(f"+{x}+{y}")
3. 拖拽支持
def on_press(event):
self._drag_x = event.x
self._drag_y = event.y
def on_drag(event):
x = root.winfo_x() + event.x - self._drag_x
y = root.winfo_y() + event.y - self._drag_y
root.geometry(f"+{x}+{y}")
root.bind("<Button-1>", on_press)
root.bind("<B1-Motion>", on_drag)
4. 右键菜单
def on_right_click(event):
menu = tk.Menu(root, tearoff=0)
# 关键:用 root.after(150, ...) 延迟打开设置弹窗
menu.add_command(label="设置", command=lambda: root.after(150, open_settings))
menu.add_command(label="刷新", command=refresh_data)
menu.add_separator()
menu.add_command(label="退出", command=root.destroy)
menu.tk_popup(event.x_root, event.y_root)
关键细节:必须使用 root.after(150, callback) 延迟打开设置弹窗,确保右键菜单完全关闭后再弹出对话框,避免焦点冲突。
5. 设置弹窗(Toplevel)
def open_settings():
# 防止重复打开多个弹窗
if self._settings_dlg is not None:
try:
self._settings_dlg.focus_force()
return
except tk.TclError:
self._settings_dlg = None
dlg = tk.Toplevel(root)
self._settings_dlg = dlg
dlg.title("设置")
dlg.configure(bg="#1A1A1A")
dlg.attributes("-topmost", True)
dlg.attributes("-alpha", 1.0) # 关键:覆盖父窗口的透明度!
dlg.resizable(False, False)
dlg.protocol("WM_DELETE_WINDOW", on_close)
Toplevel 弹窗必守规则:
| 规则 | 原因 |
|---|---|
dlg.attributes("-alpha", 1.0) |
Toplevel 会继承父窗口的 -alpha,导致弹窗背景透视 |
self._settings_dlg 守卫变量 |
防止用户多次点击后弹窗层叠冲突 |
dlg.protocol("WM_DELETE_WINDOW", on_close) |
确保用户点标题栏 × 时正确清理引用 |
dlg.after(200, entry.focus_force) |
延迟聚焦——窗口必须完全渲染后才能接收输入 |
不要对弹窗使用 overrideredirect(True) |
会同时去掉标题栏和键盘输入能力 |
6. 系统托盘图标(pystray)
使用 Pillow 动态生成价格图标,pystray 在独立线程运行:
from PIL import Image, ImageDraw, ImageFont
import pystray
def create_tray_image(price, change_pct):
"""生成 64x64 托盘图标"""
img = Image.new("RGBA", (64, 64), (40, 40, 40, 255))
draw = ImageDraw.Draw(img)
# 根据涨跌选择颜色:红涨绿跌
color = (220, 50, 50) if change_pct and change_pct > 0 \
else (50, 180, 50) if change_pct and change_pct < 0 \
else (255, 255, 255)
# 绘制价格文字和涨跌幅(省略细节,见 reference_template.py)
return img
# 在独立线程启动托盘图标
tray = pystray.Icon("widget", icon=initial_img, title="加载中...",
menu=pystray.Menu(pystray.MenuItem("退出", on_quit)))
threading.Thread(target=tray.run, daemon=True).start()
刷新数据时同步更新托盘图标:
tray.icon = create_tray_image(price, change_pct)
tray.title = f"{code} | 现价: {price:.3f} | 涨跌: {pct:+.2f}%"
7. 后台数据刷新
import threading, time
def bg_loop():
while True:
root.after(0, update_display) # 在主线程执行 UI 更新
time.sleep(REFRESH_INTERVAL)
t = threading.Thread(target=bg_loop, daemon=True)
t.start()
root.mainloop()
8. 配置持久化
JSON 配置文件存储在 exe 同目录:
import os, sys, json
CONFIG_PATH = os.path.join(
os.path.dirname(sys.executable if getattr(sys, "frozen", False) else __file__),
"config.json"
)
9. 新浪财经 API(股票行情)
HEADERS = {
"Referer": "https://finance.sina.com.cn",
"User-Agent": "Mozilla/5.0 ..."
}
url = f"https://hq.sinajs.cn/list={stock_code}" # 如 sz159516, sh600519
resp = requests.get(url, headers=HEADERS, timeout=5)
resp.encoding = "gbk"
# 解析: var hq_str_sz159516="名称,今开,昨收,现价,最高,最低,..."
# 字段: [0]名称 [1]今开 [2]昨收 [3]当前价格
股票代码标准化规则:6xxxxx/9xxxxx → 上海(sh前缀),其余 → 深圳(sz前缀)。
打包发布
pyinstaller --onefile --noconsole --name WidgetName main.py
--noconsole 隐藏控制台窗口。输出位于 dist/WidgetName.exe。
已知踩坑记录
| 坑点 | 现象 | 修复方案 |
|---|---|---|
缺少 ctypes.wintypes 导入 |
AttributeError: module 'ctypes' has no attribute 'wintypes' |
显式写 import ctypes.wintypes |
Toplevel 继承 -alpha |
设置弹窗背景透视,能看到桌面内容 | 设置 dlg.attributes("-alpha", 1.0) |
弹窗使用 overrideredirect |
Entry 输入框无法接收键盘输入 | 弹窗不要用 overrideredirect |
| 菜单直接打开弹窗 | 第一次点击无效,第二次才生效 | 使用 root.after(150, callback) 延迟 |
| 无重复弹窗守卫 | 多个设置弹窗层叠冲突 | 用 self._settings_dlg 追踪实例 |
focus_force 过早调用 |
聚焦静默失败,输入框不工作 | 用 dlg.after(200, ...) 延迟 |
完整模板
参见 reference_template.py,包含完整的可运行代码模板。