复制代码
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import json, os, sys, time, threading, platform, datetime, subprocess
import pyperclip
# ── 依赖检查 ──────────────────────────────────────────────────────────────────
_missing = []
try:
import pyperclip
except ImportError:
_missing.append("pyperclip")
# 使用 pynput 替代 pyautogui(Linux 兼容性更好)
try:
from pynput import mouse, keyboard
USE_PYNPUT = True
except ImportError:
_missing.append("pynput")
USE_PYNPUT = False
if _missing:
subprocess.check_call([sys.executable, "-m", "pip", "install"] + _missing)
import importlib
for m in _missing:
importlib.import_module(m)
if 'pynput' in _missing:
from pynput import mouse, keyboard
USE_PYNPUT = True
# 检测 xdotool 是否可用(用于组合键)
HAS_XDOTOOL = False
XDOTOOL_VERSION = "unknown"
try:
result = subprocess.run(['which', 'xdotool'], capture_output=True, text=True)
if result.returncode == 0:
HAS_XDOTOOL = True
# 获取版本信息
try:
version_result = subprocess.run(['xdotool', '--version'],
capture_output=True, text=True, timeout=2)
if version_result.returncode == 0:
XDOTOOL_VERSION = version_result.stdout.strip()
except:
pass
print(f"[INFO] xdotool 已检测到 (版本: {XDOTOOL_VERSION}),将用于键盘控制")
else:
print("[WARNING] xdotool 未找到,建议安装: sudo apt-get install xdotool")
except Exception as e:
print(f"[WARNING] 检测 xdotool 失败: {e}")
# ── 常量 ──────────────────────────────────────────────────────────────────────
BASE_W, BASE_H = 1920, 1080
# 兼容打包环境的路径获取
def get_program_dir():
"""获取程序所在目录,支持打包环境"""
if getattr(sys, 'frozen', False):
# 打包后的可执行文件
exe_path = sys.executable
# 检查是否在 /tmp 或其他临时目录中(onefile 模式)
if exe_path.startswith('/tmp/') or 'onefile' in exe_path.lower():
# 尝试从环境变量或命令行参数获取原始路径
# 优先使用当前工作目录
cwd = os.getcwd()
if cwd and os.path.exists(cwd):
return cwd
# 其次尝试 HOME 目录
home_dir = os.path.expanduser("~")
if home_dir and os.path.exists(home_dir):
return home_dir
# 最后返回桌面目录
desktop = os.path.join(home_dir, "Desktop")
if os.path.exists(desktop):
return desktop
return home_dir
else:
# 正常打包(非 onefile 模式)
return os.path.dirname(exe_path)
else:
# 普通 Python 脚本运行
return os.path.dirname(os.path.abspath(__file__))
PROGRAM_DIR = get_program_dir()
DEFAULT_ROWS = 80
BG_COLORS = ["#FFFDE7", "#FFF9C4"] # 间隔底色(淡黄系)
WIN_BG = "#FFFDE7"
BTN_COLORS = ["#FF8A80", "#FFD180", "#CCFF90", "#80D8FF", "#EA80FC"] # 糖果色
FONT_NAME = "仿宋" if platform.system() == "Windows" else "Fangsong Ti"
FONT = (FONT_NAME, 12)
# 命令列表 - 扩展版,包含所有常用按键和组合键
CMD_LIST = [
# 鼠标操作
"单击左键", "单击右键", "双击左键",
# 基础按键
"Enter(回车)", "Space(空格)", "Tab(制表)", "Esc(退出)",
"Backspace(退格)", "Delete(删除)",
# 功能键
"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
# 方向键
"Up(上)", "Down(下)", "Left(左)", "Right(右)",
"Home", "End", "Page Up", "Page Down",
# 编辑键
"Insert", "Print Screen", "Scroll Lock", "Pause",
# 数字键盘
"NumLock", "Numpad Enter",
# 修饰键
"Ctrl", "Alt", "Shift", "Win(徽标键)",
# 组合键 - Windows系统
"Alt+Tab(切换窗口)", "Alt+F4(关闭窗口)", "Ctrl+C(复制)", "Ctrl+V(粘贴)",
"Ctrl+X(剪切)", "Ctrl+A(全选)", "Ctrl+S(保存)", "Ctrl+Z(撤销)",
"Ctrl+Y(重做)", "Ctrl+F(查找)", "Ctrl+P(打印)", "Ctrl+N(新建)",
"Ctrl+W(关闭标签)", "Ctrl+T(新标签)", "Ctrl+Shift+T(恢复标签)",
"Alt+Enter(属性)", "Win+D(显示桌面)", "Win+E(资源管理器)",
"Win+L(锁屏)", "Win+R(运行)", "Win+Tab(任务视图)",
# 组合键 - 通用
"Shift+Delete(永久删除)", "Ctrl+Shift+Esc(任务管理器)",
"Alt+Space(窗口菜单)", "Ctrl+Home(文档开头)", "Ctrl+End(文档结尾)",
"Ctrl+Left(单词左移)", "Ctrl+Right(单词右移)",
"Shift+Home(选中到行首)", "Shift+End(选中到行尾)",
# 特殊操作
"粘贴", "读取并粘贴", "延时"
]
CMD_HELP = (
"命令说明:\n"
"═══════════════════════════════════════\n\n"
"【鼠标操作】\n"
" 单击左键 → 中间填 x,y 在该坐标左键单击(留空则在鼠标当前位置点击)\n"
" 单击右键 → 中间填 x,y 在该坐标右键单击(留空则在鼠标当前位置点击)\n"
" 双击左键 → 中间填 x,y 在该坐标左键双击(留空则在鼠标当前位置点击)\n\n"
"【键盘按键】\n"
" 单个按键 → 直接选择对应按键即可执行\n"
" 如: F2, Enter, Space, Tab, Esc, Page Up等\n\n"
"【组合键】\n"
" 自动识别 → 选择组合键后自动执行,无需额外参数\n"
" 如: Alt+Tab, Ctrl+C, Win+D等\n\n"
"【特殊命令】\n"
" 粘贴 → 中间填文字 先点左键再粘贴内容(右侧备注)\n"
" 延时 → 中间填秒数 额外等待指定秒数(如: 1.5)\n"
" 读取并粘贴→ 从同目录xlsx第2列依次读取并粘贴\n\n"
"【全局设置】\n"
" 命令间延时 → 每个命令执行后的基础延时(默认0.01秒)\n"
" 循环次数 → 整个命令序列重复执行的次数(默认1次)\n"
" 每行延时 → 每行命令之间的额外延时(默认0.02秒)\n\n"
"【快捷键】\n"
" Ctrl+B → 启动/停止执行\n"
" 右键菜单 → 添加/删除行\n\n"
"💡 提示:可以通过'延时'命令精确控制每一步的等待时间"
)
# ── 主应用 ────────────────────────────────────────────────────────────────────
class App(tk.Tk):
def __init__(self):
super().__init__()
print("[DEBUG] 初始化应用...")
self.title("循环粘贴工具 v2.0 (增强版)")
self._running = False
self._run_thread = None
self._xlsx_row = 0 # xlsx 当前读取行(0-based)
self._drag_x = self._drag_y = 0
# 热键监听器
self._hotkey_listener = None
# 当前记录文件路径
self.current_file = None
# pynput 控制器
print("[DEBUG] 创建 pynput 控制器...")
self.mouse_ctrl = mouse.Controller()
self.keyboard_ctrl = keyboard.Controller()
print("[DEBUG] pynput 控制器创建成功")
# 分辨率缩放比
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
self.sx = sw / BASE_W
self.sy = sh / BASE_H
print(f"[DEBUG] 屏幕分辨率: {sw}x{sh}, 缩放比: {self.sx:.2f}x{self.sy:.2f}")
# 加载默认配置(空白)
self.cfg = self._get_default_cfg()
# 窗口尺寸/位置
DEFAULT_WIDTH = 1230
self.geometry(f"{DEFAULT_WIDTH}x800+100+50")
self.configure(bg=WIN_BG)
self.minsize(600, 400)
print("[DEBUG] 构建 UI...")
self._build_ui()
print("[DEBUG] 加载行...")
self._load_rows()
# 绑定 Ctrl+B - 使用 pynput 或 tkinter 内置绑定
print("[DEBUG] 设置热键...")
self._setup_hotkey()
self.protocol("WM_DELETE_WINDOW", self._on_close)
# 启动时清理修饰键状态
self._clear_modifiers()
print("[DEBUG] 应用初始化完成")
def _clear_modifiers(self):
"""清理所有修饰键状态,防止卡键"""
print("[DEBUG] 清理修饰键状态...")
if HAS_XDOTOOL:
try:
# 使用 xdotool 清除所有修饰键(多次确保释放)
modifiers = ['Control_L', 'Control_R', 'Alt_L', 'Alt_R',
'Shift_L', 'Shift_R', 'Super_L', 'Super_R']
for _ in range(2): # 重复两次确保释放
for mod in modifiers:
subprocess.run(['xdotool', 'keyup', mod],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.02)
print("[DEBUG] 修饰键清理完成 (xdotool)")
except Exception as e:
print(f"[WARNING] xdotool 清理修饰键失败: {e}")
# 同时使用 pynput 清理(双重保障)
try:
from pynput.keyboard import Key
modifier_keys = [
Key.ctrl_l, Key.ctrl_r,
Key.alt_l, Key.alt_r,
Key.shift_l, Key.shift_r,
Key.cmd_l, Key.cmd_r
]
for key in modifier_keys:
self.keyboard_ctrl.release(key)
print("[DEBUG] 修饰键清理完成 (pynput)")
except Exception as e:
print(f"[WARNING] pynput 清理修饰键失败: {e}")
def _ensure_key_released(self, key_name):
"""确保指定按键被释放(在执行组合键前调用)"""
if HAS_XDOTOOL:
xdotool_map = {
'ctrl': ['Control_L', 'Control_R'],
'alt': ['Alt_L', 'Alt_R'],
'shift': ['Shift_L', 'Shift_R'],
'win': ['Super_L', 'Super_R'],
}
keys_to_release = xdotool_map.get(key_name.lower(), [])
# 重复两次确保释放
for _ in range(2):
for key in keys_to_release:
try:
subprocess.run(['xdotool', 'keyup', key],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except:
pass
time.sleep(0.01)
# 同时使用 pynput 清理
try:
from pynput.keyboard import Key
release_map = {
'ctrl': [Key.ctrl_l, Key.ctrl_r],
'alt': [Key.alt_l, Key.alt_r],
'shift': [Key.shift_l, Key.shift_r],
'win': [Key.cmd_l, Key.cmd_r],
}
keys_to_release = release_map.get(key_name.lower(), [])
for key in keys_to_release:
self.keyboard_ctrl.release(key)
except:
pass
def _get_default_cfg(self):
"""获取默认配置"""
return {
"geometry": f"{1230}x800+100+50",
"cmd_delay": "0.01",
"loop_count": "1",
"line_delay": "0.05", # 增加到 0.05 秒,防止按键被吞
"xlsx_row": 0,
"rows": []
}
def _get_pynput_key(self, key_name):
"""将按键名称转换为 pynput 按键对象"""
special_keys = {
'enter': keyboard.Key.enter,
'space': keyboard.Key.space,
'tab': keyboard.Key.tab,
'esc': keyboard.Key.esc,
'escape': keyboard.Key.esc,
'backspace': keyboard.Key.backspace,
'delete': keyboard.Key.delete,
'up': keyboard.Key.up,
'down': keyboard.Key.down,
'left': keyboard.Key.left,
'right': keyboard.Key.right,
'home': keyboard.Key.home,
'end': keyboard.Key.end,
'pageup': keyboard.Key.page_up,
'pagedown': keyboard.Key.page_down,
'insert': keyboard.Key.insert,
'printscreen': keyboard.Key.print_screen,
'scrolllock': keyboard.Key.scroll_lock,
'pause': keyboard.Key.pause,
'numlock': keyboard.Key.num_lock,
'ctrl': keyboard.Key.ctrl_l,
'alt': keyboard.Key.alt_l,
'shift': keyboard.Key.shift_l,
'win': keyboard.Key.cmd,
'f1': keyboard.Key.f1,
'f2': keyboard.Key.f2,
'f3': keyboard.Key.f3,
'f4': keyboard.Key.f4,
'f5': keyboard.Key.f5,
'f6': keyboard.Key.f6,
'f7': keyboard.Key.f7,
'f8': keyboard.Key.f8,
'f9': keyboard.Key.f9,
'f10': keyboard.Key.f10,
'f11': keyboard.Key.f11,
'f12': keyboard.Key.f12,
}
return special_keys.get(key_name.lower(), key_name)
def _setup_hotkey(self):
"""设置热键监听"""
try:
from pynput import keyboard
def on_press(key):
try:
if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
self._ctrl_pressed = True
elif hasattr(self, '_ctrl_pressed') and self._ctrl_pressed:
if hasattr(key, 'char') and key.char == 'b':
self._toggle_run()
except AttributeError:
pass
def on_release(key):
try:
if key == keyboard.Key.ctrl_l or key == keyboard.Key.ctrl_r:
self._ctrl_pressed = False
except AttributeError:
pass
self._ctrl_pressed = False
self._hotkey_listener = keyboard.Listener(
on_press=on_press,
on_release=on_release
)
self._hotkey_listener.start()
except Exception as e:
print(f"pynput 热键监听失败: {e}")
self._fallback_hotkey()
def _fallback_hotkey(self):
"""降级方案:使用 tkinter 内置绑定(仅当窗口聚焦时有效)"""
self.bind('<Control-b>', lambda e: self._toggle_run())
self.bind('<Control-B>', lambda e: self._toggle_run())
# 显示提示
msg = "⚠️ 全局热键不可用\n\n"
msg += "原因:Linux 系统下需要特殊权限\n\n"
msg += "解决方案:\n"
msg += "1. 保持本窗口处于激活状态\n"
msg += "2. 使用 Ctrl+B 快捷键\n"
msg += "3. 或直接点击'保 存'按钮旁的说明查看帮助\n\n"
msg += "建议安装 pynput: pip install pynput"
self.after(1000, lambda: messagebox.showwarning("热键提示", msg))
# ── 文件管理 ────────────────────────────────────────────────────────────
def _select_record_file(self):
"""选择记录文件"""
file_path = filedialog.askopenfilename(
title="选择记录文件",
initialdir=PROGRAM_DIR,
filetypes=[("JSON记录文件", "*.json"), ("所有文件", "*.*")]
)
if file_path:
self._load_from_file(file_path)
def _load_from_file(self, file_path):
"""从指定文件加载配置"""
try:
with open(file_path, "r", encoding="utf-8") as f:
loaded_cfg = json.load(f)
self.current_file = file_path
self.cfg = loaded_cfg
# 更新UI显示
self.var_cmd_delay.set(self.cfg.get("cmd_delay", "0.01"))
self.var_loop_count.set(self.cfg.get("loop_count", "1"))
self.var_line_delay.set(self.cfg.get("line_delay", "0.05")) # 修复:默认值改为 0.05
self._xlsx_row = self.cfg.get("xlsx_row", 0)
# 重新加载行
self._clear_rows()
self._load_rows_from_cfg()
# 更新窗口标题
filename = os.path.basename(file_path)
self.title(f"循环粘贴工具 v2.0 - {filename}")
self._update_status(f"✓ 已加载: {filename}")
except Exception as e:
messagebox.showerror("加载失败", f"无法加载文件:\n{str(e)}")
def _save_to_current_file(self):
"""保存到当前文件,如果没有则创建新文件"""
if self.current_file:
# 保存到已有文件
self._do_save(self.current_file)
else:
# 创建新文件(以日期命名)
self._save_as_new_file()
def _save_as_new_file(self):
"""保存为新文件(以日期时间命名)"""
now = datetime.datetime.now()
filename = now.strftime("记录_%Y%m%d_%H%M%S.json")
file_path = os.path.join(PROGRAM_DIR, filename)
# 如果文件已存在,添加序号
counter = 1
while os.path.exists(file_path):
filename = now.strftime(f"记录_%Y%m%d_%H%M%S_{counter}.json")
file_path = os.path.join(PROGRAM_DIR, filename)
counter += 1
self._do_save(file_path)
self.current_file = file_path
# 更新窗口标题
self.title(f"循环粘贴工具 v2.0 - {filename}")
self._update_status(f"✓ 已创建新文件: {filename}")
def _save_as_dialog(self):
"""另存为对话框"""
now = datetime.datetime.now()
default_name = now.strftime("记录_%Y%m%d_%H%M%S.json")
file_path = filedialog.asksaveasfilename(
title="另存为",
initialdir=PROGRAM_DIR,
initialfile=default_name,
defaultextension=".json",
filetypes=[("JSON记录文件", "*.json"), ("所有文件", "*.*")]
)
if file_path:
self._do_save(file_path)
self.current_file = file_path
# 更新窗口标题
filename = os.path.basename(file_path)
self.title(f"循环粘贴工具 v2.0 - {filename}")
self._update_status(f"✓ 已保存: {filename}")
def _do_save(self, file_path):
"""执行保存操作"""
try:
self.cfg["geometry"] = self.geometry()
self.cfg["cmd_delay"] = self.var_cmd_delay.get()
self.cfg["loop_count"] = self.var_loop_count.get()
self.cfg["line_delay"] = self.var_line_delay.get()
self.cfg["xlsx_row"] = self._xlsx_row
# 保存每行数据
rows = []
for row in self.rows:
rows.append({
"cmd": row["cmd"].get(),
"mid": row["mid"].get(),
"remark": row["rem"].get(),
})
self.cfg["rows"] = rows
with open(file_path, "w", encoding="utf-8") as f:
json.dump(self.cfg, f, ensure_ascii=False, indent=2)
filename = os.path.basename(file_path)
self._update_status(f"✓ 已保存: {filename}")
except Exception as e:
messagebox.showerror("保存失败", f"保存文件时出错:\n{str(e)}")
def _clear_rows(self):
"""清空所有行"""
for rd in self.rows:
rd["frame"].destroy()
self.rows.clear()
def _load_rows_from_cfg(self):
"""从配置加载行"""
saved = self.cfg.get("rows", [])
# 如果有保存的行,使用保存的数量;否则使用默认数量
if saved:
count = len(saved)
else:
count = DEFAULT_ROWS
for i in range(count):
d = saved[i] if i < len(saved) else {}
rd = self._make_row(i,
cmd=d.get("cmd", ""),
mid=d.get("mid", ""),
rem=d.get("remark", ""))
self.rows.append(rd)
def _update_status(self, message):
"""更新状态标签"""
if hasattr(self, 'lbl_status'):
self.lbl_status.config(text=message)
self.after(3000, lambda: self.lbl_status.config(
text="●就绪", fg="#9E9E9E"
))
# ── UI 构建 ───────────────────────────────────────────────────────────────
def _build_ui(self):
top = tk.Frame(self, bg=WIN_BG)
top.pack(fill="x", padx=6, pady=4)
file_btn = tk.Button(top, text="打开记录", font=FONT, bg=BTN_COLORS[3],
relief="raised", bd=2, command=self._select_record_file)
file_btn.pack(side="left", padx=2)
tk.Label(top, text="命令间延时(s):", bg=WIN_BG, font=FONT).pack(side="left", padx=(10, 0))
self.var_cmd_delay = tk.StringVar(value=self.cfg.get("cmd_delay", "0.01"))
tk.Entry(top, textvariable=self.var_cmd_delay, width=6, font=FONT).pack(side="left", padx=2)
tk.Label(top, text=" 循环次数:", bg=WIN_BG, font=FONT).pack(side="left")
self.var_loop_count = tk.StringVar(value=self.cfg.get("loop_count", "1"))
tk.Entry(top, textvariable=self.var_loop_count, width=6, font=FONT).pack(side="left", padx=2)
tk.Label(top, text=" 每行延时(s):", bg=WIN_BG, font=FONT).pack(side="left")
self.var_line_delay = tk.StringVar(value=self.cfg.get("line_delay", "0.05")) # 修复:默认值改为 0.05
tk.Entry(top, textvariable=self.var_line_delay, width=6, font=FONT).pack(side="left", padx=2)
help_btn = tk.Button(top, text="命令说明", font=FONT, bg=BTN_COLORS[3],
relief="raised", bd=2,
command=lambda: messagebox.showinfo("命令说明", CMD_HELP))
help_btn.pack(side="left", padx=8)
save_btn = tk.Button(top, text="保 存", font=FONT, bg=BTN_COLORS[2],
relief="raised", bd=2, command=self._save_to_current_file)
save_btn.pack(side="left", padx=4)
saveas_btn = tk.Button(top, text="另存为", font=FONT, bg=BTN_COLORS[1],
relief="raised", bd=2, command=self._save_as_dialog)
saveas_btn.pack(side="left", padx=4)
hotkey_hint = "Ctrl+B"
tk.Label(top, text=f" 【{hotkey_hint}】启动/停止", bg=WIN_BG,
font=(FONT_NAME, 11, "bold"), fg="#E53935").pack(side="left", padx=10)
self.lbl_status = tk.Label(top, text="●就绪", bg=WIN_BG,
font=(FONT_NAME, 11, "bold"), fg="#9E9E9E")
self.lbl_status.pack(side="right", padx=6)
hdr = tk.Frame(self, bg=WIN_BG)
hdr.pack(fill="x", padx=6)
tk.Label(hdr, text="命令", width=18, font=FONT, bg="#FFF176",
relief="groove").pack(side="left", padx=1)
tk.Label(hdr, text="中间参数 / 粘贴内容", width=100, font=FONT, bg="#FFF176",
relief="groove").pack(side="left", padx=1)
tk.Label(hdr, text="备注", width=30, font=FONT, bg="#FFF176",
relief="groove").pack(side="left", padx=1)
container = tk.Frame(self, bg=WIN_BG)
container.pack(fill="both", expand=True, padx=6, pady=2)
canvas = tk.Canvas(container, bg=WIN_BG, highlightthickness=0)
vsb = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
self.inner = tk.Frame(canvas, bg=WIN_BG)
self._canvas_win = canvas.create_window((0, 0), window=self.inner, anchor="nw")
self.inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.bind("<Configure>",
lambda e: canvas.itemconfig(self._canvas_win, width=e.width))
self.inner.bind_all("<MouseWheel>",
lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"))
self._canvas = canvas
self.rows = []
def _make_row(self, idx, cmd="", mid="", rem=""):
bg = BG_COLORS[idx % 2]
frm = tk.Frame(self.inner, bg=bg)
frm.pack(fill="x", pady=0)
var_cmd = tk.StringVar(value=cmd if cmd else "单击左键")
var_mid = tk.StringVar(value=mid)
var_rem = tk.StringVar(value=rem)
cmb = ttk.Combobox(frm, textvariable=var_cmd, values=CMD_LIST,
width=17, font=FONT, state="normal")
cmb.pack(side="left", padx=1, pady=1)
ent_mid = tk.Entry(frm, textvariable=var_mid, width=100, font=FONT,
bg=bg, relief="flat", bd=0,
highlightthickness=1, highlightbackground=bg,
highlightcolor="#BDBDBD")
ent_mid.pack(side="left", padx=1)
ent_rem = tk.Entry(frm, textvariable=var_rem, width=30, font=FONT,
bg=bg, relief="flat", bd=0,
highlightthickness=1, highlightbackground=bg,
highlightcolor="#BDBDBD")
ent_rem.pack(side="left", padx=1, fill="x", expand=True)
row_data = {"frame": frm, "cmd": var_cmd, "mid": var_mid, "rem": var_rem, "bg": bg}
menu = tk.Menu(self, tearoff=0)
menu.add_command(label="在上方添加行",
command=lambda rd=row_data: self._insert_before(rd))
menu.add_command(label="在下方添加行",
command=lambda rd=row_data: self._insert_after(rd))
menu.add_command(label="删除本行",
command=lambda rd=row_data: self._delete_row(rd))
for widget in (frm, cmb, ent_mid, ent_rem):
widget.bind("<Button-3>",
lambda e, m=menu: m.post(e.x_root, e.y_root))
return row_data
def _insert_before(self, target_rd):
idx = self._row_index(target_rd)
new_rd = self._make_row(idx)
new_rd["frame"].pack_forget()
self.rows.insert(idx, new_rd)
self._repack_rows()
def _insert_after(self, target_rd):
idx = self._row_index(target_rd)
new_rd = self._make_row(idx + 1)
new_rd["frame"].pack_forget()
self.rows.insert(idx + 1, new_rd)
self._repack_rows()
def _delete_row(self, target_rd):
idx = self._row_index(target_rd)
target_rd["frame"].destroy()
self.rows.pop(idx)
self._repack_rows()
def _row_index(self, rd):
return self.rows.index(rd)
def _repack_rows(self):
for i, rd in enumerate(self.rows):
bg = BG_COLORS[i % 2]
rd["bg"] = bg
rd["frame"].configure(bg=bg)
for child in rd["frame"].winfo_children():
try:
child.configure(bg=bg)
except Exception:
pass
try:
child.configure(highlightbackground=bg)
except Exception:
pass
rd["frame"].pack_forget()
for rd in self.rows:
rd["frame"].pack(fill="x", pady=0)
def _load_rows(self):
self._xlsx_row = 0
count = DEFAULT_ROWS
for i in range(count):
rd = self._make_row(i, cmd="", mid="", rem="")
self.rows.append(rd)
# ── 执行引擎 ──────────────────────────────────────────────────────────────
def _toggle_run(self):
if self._running:
self._running = False
self._clear_modifiers()
self.after(0, lambda: self.lbl_status.configure(text="●停止", fg="#9E9E9E"))
else:
self._running = True
self.after(0, lambda: self.lbl_status.configure(text="▶运行中", fg="#43A047"))
self._run_thread = threading.Thread(target=self._run_commands, daemon=True)
self._run_thread.start()
def _run_commands(self):
try:
cmd_delay = float(self.var_cmd_delay.get())
line_delay = float(self.var_line_delay.get())
except ValueError:
cmd_delay, line_delay = 0.01, 0.05 # 修复:line_delay 默认为 0.05
try:
loop_count = max(1, int(self.var_loop_count.get()))
except ValueError:
loop_count = 1
for _loop in range(loop_count):
if not self._running:
break
# 每次循环开始前清理修饰键
if _loop > 0:
self._clear_modifiers()
time.sleep(0.05)
for rd in self.rows:
if not self._running:
break
cmd = rd["cmd"].get().strip()
mid = rd["mid"].get().strip()
if not cmd:
continue
try:
self._exec_one(cmd, mid, cmd_delay)
except Exception as e:
print(f"[ERROR] {cmd} / {mid} => {e}")
time.sleep(line_delay)
self._running = False
self._clear_modifiers()
self.after(0, lambda: self.lbl_status.configure(text="●完成", fg="#1565C0"))
def _exec_one(self, cmd, mid, cmd_delay):
def exec_key_xdotool(key_str, max_retries=3, use_clearmodifiers=True):
"""使用 xdotool 执行按键(带重试机制)"""
for attempt in range(max_retries):
try:
if use_clearmodifiers:
xdotool_cmd = ['xdotool', 'key', '--clearmodifiers', '--delay', '20', key_str]
else:
xdotool_cmd = ['xdotool', 'key', '--delay', '20', key_str]
result = subprocess.run(
xdotool_cmd,
timeout=2,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
if result.returncode == 0:
# 如果使用了 --clearmodifiers,执行后再次确保修饰键释放
if use_clearmodifiers:
time.sleep(0.02)
self._ensure_key_released('ctrl')
self._ensure_key_released('alt')
self._ensure_key_released('shift')
return True
else:
if attempt < max_retries - 1:
time.sleep(0.05)
continue
except Exception:
if attempt < max_retries - 1:
time.sleep(0.05)
continue
print(f"[WARNING] xdotool 执行失败 '{key_str}',切换到 pynput")
return False
def press_combo_with_xdotool(modifiers, key, max_retries=3):
"""使用 xdotool 执行组合键(带重试)"""
if not HAS_XDOTOOL:
return False
for attempt in range(max_retries):
try:
xdotool_map = {
'ctrl': 'Control_L', 'alt': 'Alt_L', 'shift': 'Shift_L', 'win': 'Super_L',
'enter': 'Return', 'tab': 'Tab', 'space': 'space', 'esc': 'Escape',
'delete': 'Delete', 'home': 'Home', 'end': 'End',
'left': 'Left', 'right': 'Right', 'up': 'Up', 'down': 'Down',
'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5',
'f6': 'F6', 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10',
'f11': 'F11', 'f12': 'F12',
'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f',
'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l',
'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r',
's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x',
'y': 'y', 'z': 'z',
}
key_parts = [xdotool_map.get(m.lower(), m) for m in modifiers]
key_parts.append(xdotool_map.get(key.lower(), key))
combo_str = '+'.join(key_parts)
result = subprocess.run(
['xdotool', 'key', '--clearmodifiers', '--delay', '20', combo_str],
timeout=2,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
if result.returncode == 0:
return True
else:
if attempt < max_retries - 1:
time.sleep(0.05)
continue
except Exception:
if attempt < max_retries - 1:
time.sleep(0.05)
continue
print(f"[WARNING] xdotool 组合键失败,切换到 pynput")
return False
def press_key(key_name):
"""执行单个按键 - 优化版本,保持窗口焦点"""
xdotool_map = {
'enter': 'Return', 'space': 'space', 'tab': 'Tab', 'esc': 'Escape',
'backspace': 'BackSpace', 'delete': 'Delete',
'up': 'Up', 'down': 'Down', 'left': 'Left', 'right': 'Right',
'home': 'Home', 'end': 'End', 'pageup': 'Page_Up', 'pagedown': 'Page_Down',
'insert': 'Insert', 'printscreen': 'Print', 'scrolllock': 'Scroll_Lock',
'pause': 'Pause', 'numlock': 'Num_Lock',
'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5',
'f6': 'F6', 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10',
'f11': 'F11', 'f12': 'F12',
'ctrl': 'Control_L', 'alt': 'Alt_L', 'shift': 'Shift_L', 'win': 'Super_L',
}
xdotool_key = xdotool_map.get(key_name.lower(), key_name)
# 对于方向键等导航键,使用最简化的方式,绝不回退到 pynput
navigation_keys = ['up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown']
is_navigation = key_name.lower() in navigation_keys
if HAS_XDOTOOL:
if is_navigation:
# 导航键:使用最简单的 key 命令
# 不使用 windowactivate,避免时序问题导致按键被吞
try:
result = subprocess.run(['xdotool', 'key', xdotool_key],
timeout=2,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
if result.returncode != 0:
print(f"[WARNING] xdotool 导航键返回码异常: {result.returncode}")
# 等待确保按键被系统处理(关键!)
time.sleep(0.05)
return
except Exception as e:
print(f"[WARNING] xdotool 导航键失败 '{key_name}': {e}")
# 其他键使用标准方式(但不使用 --clearmodifiers)
success = exec_key_xdotool(xdotool_key, max_retries=3, use_clearmodifiers=False)
if not success:
# 回退到 pynput
try:
k = self._get_pynput_key(key_name)
self.keyboard_ctrl.press(k)
time.sleep(0.05)
self.keyboard_ctrl.release(k)
except Exception as e:
print(f"[按键错误] {key_name}: {e}")
else:
try:
k = self._get_pynput_key(key_name)
self.keyboard_ctrl.press(k)
time.sleep(0.05)
self.keyboard_ctrl.release(k)
except Exception as e:
print(f"[按键错误] {key_name}: {e}")
def press_hotkey(*keys):
"""执行组合键"""
if len(keys) < 2:
return
modifier_names = ['ctrl', 'alt', 'shift', 'win']
modifiers = [k for k in keys[:-1] if k.lower() in modifier_names]
main_key = keys[-1]
# 执行前确保所有修饰键都是释放状态
for mod in modifiers:
self._ensure_key_released(mod)
time.sleep(0.02) # 短暂等待确保释放完成
if HAS_XDOTOOL:
success = press_combo_with_xdotool(modifiers, main_key, max_retries=3)
if success:
# 执行后再次清理修饰键
for mod in modifiers:
self._ensure_key_released(mod)
return
try:
pynput_keys = [self._get_pynput_key(k) for k in keys]
for key in pynput_keys:
self.keyboard_ctrl.press(key)
time.sleep(0.01)
for key in reversed(pynput_keys):
self.keyboard_ctrl.release(key)
time.sleep(0.01)
# 执行后再次清理修饰键
for mod in modifiers:
self._ensure_key_released(mod)
except Exception as e:
print(f"[组合键错误] {'+'.join(keys)}: {e}")
# 异常时也要清理
for mod in modifiers:
self._ensure_key_released(mod)
def press_alt_tab_xdotool(max_retries=3):
"""专门优化的 Alt+Tab 实现(带重试和清理)"""
if not HAS_XDOTOOL:
return False
for attempt in range(max_retries):
alt_pressed = False
try:
# 1. 先确保 Alt 是释放状态(双重保障)
self._ensure_key_released('alt')
time.sleep(0.02)
# 2. 再次用 xdotool 确保释放
subprocess.run(['xdotool', 'keyup', 'Alt_L'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(['xdotool', 'keyup', 'Alt_R'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.02)
# 3. 按下 Alt
result1 = subprocess.run(['xdotool', 'keydown', 'Alt_L'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result1.returncode != 0:
raise Exception("keydown failed")
alt_pressed = True
time.sleep(0.12)
# 4. 按 Tab
result2 = subprocess.run(['xdotool', 'key', '--delay', '20', 'Tab'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result2.returncode != 0:
raise Exception("key Tab failed")
time.sleep(0.08)
# 5. 释放 Alt
result3 = subprocess.run(['xdotool', 'keyup', 'Alt_L'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result3.returncode != 0:
raise Exception("keyup failed")
alt_pressed = False
# 6. 再次确保 Alt 完全释放
self._ensure_key_released('alt')
return True
except Exception:
# 确保释放 Alt 键(防止卡住)
if alt_pressed:
try:
subprocess.run(['xdotool', 'keyup', 'Alt_L'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(['xdotool', 'keyup', 'Alt_R'],
timeout=1, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self._ensure_key_released('alt')
print("[DEBUG] 异常处理:已释放 Alt 键")
except:
pass
if attempt < max_retries - 1:
time.sleep(0.1)
continue
print("[WARNING] xdotool Alt+Tab 失败,切换到 pynput")
return press_alt_tab_pynput()
def press_alt_tab_pynput():
"""pynput 版本的 Alt+Tab"""
try:
# 执行前确保 Alt 已释放
self._ensure_key_released('alt')
time.sleep(0.02)
alt_key = keyboard.Key.alt_l
tab_key = keyboard.Key.tab
self.keyboard_ctrl.press(alt_key)
time.sleep(0.15)
self.keyboard_ctrl.press(tab_key)
time.sleep(0.05)
self.keyboard_ctrl.release(tab_key)
time.sleep(0.05)
self.keyboard_ctrl.release(alt_key)
# 执行后再次确保 Alt 释放
self._ensure_key_released('alt')
return True
except Exception as e:
print(f"[Alt+Tab pynput 错误]: {e}")
# 异常时也要确保释放
try:
self._ensure_key_released('alt')
except:
pass
return False
def parse_xy(s):
s = s.strip()
if not s:
return None
parts = s.replace(",", ",").split(",")
if len(parts) < 2:
return None
try:
x = int(float(parts[0].strip()) * self.sx)
y = int(float(parts[1].strip()) * self.sy)
return x, y
except (ValueError, IndexError):
return None
# ── 鼠标操作 ──
if cmd in ("单击左键",):
xy = parse_xy(mid)
time.sleep(cmd_delay)
if xy:
self.mouse_ctrl.position = (xy[0], xy[1])
time.sleep(0.05)
self.mouse_ctrl.click(mouse.Button.left)
elif cmd in ("单击右键",):
xy = parse_xy(mid)
time.sleep(cmd_delay)
if xy:
self.mouse_ctrl.position = (xy[0], xy[1])
time.sleep(0.05)
self.mouse_ctrl.click(mouse.Button.right)
elif cmd in ("双击左键",):
xy = parse_xy(mid)
time.sleep(cmd_delay)
if xy:
self.mouse_ctrl.position = (xy[0], xy[1])
time.sleep(0.05)
self.mouse_ctrl.click(mouse.Button.left, 2)
# ── 基础按键 ──
elif cmd == "Enter(回车)":
time.sleep(cmd_delay)
press_key('enter')
elif cmd == "Space(空格)":
time.sleep(cmd_delay)
press_key('space')
elif cmd == "Tab(制表)":
time.sleep(cmd_delay)
press_key('tab')
elif cmd == "Esc(退出)":
time.sleep(cmd_delay)
press_key('esc')
elif cmd == "Backspace(退格)":
time.sleep(cmd_delay)
press_key('backspace')
elif cmd == "Delete(删除)":
time.sleep(cmd_delay)
press_key('delete')
# ── 功能键 ──
elif cmd == "F1":
time.sleep(cmd_delay)
press_key('f1')
elif cmd == "F2":
time.sleep(cmd_delay)
press_key('f2')
elif cmd == "F3":
time.sleep(cmd_delay)
press_key('f3')
elif cmd == "F4":
time.sleep(cmd_delay)
press_key('f4')
elif cmd == "F5":
time.sleep(cmd_delay)
press_key('f5')
elif cmd == "F6":
time.sleep(cmd_delay)
press_key('f6')
elif cmd == "F7":
time.sleep(cmd_delay)
press_key('f7')
elif cmd == "F8":
time.sleep(cmd_delay)
press_key('f8')
elif cmd == "F9":
time.sleep(cmd_delay)
press_key('f9')
elif cmd == "F10":
time.sleep(cmd_delay)
press_key('f10')
elif cmd == "F11":
time.sleep(cmd_delay)
press_key('f11')
elif cmd == "F12":
time.sleep(cmd_delay)
press_key('f12')
# ── 方向键 ──
elif cmd == "Up(上)":
time.sleep(cmd_delay)
press_key('up')
elif cmd == "Down(下)":
time.sleep(cmd_delay)
press_key('down')
elif cmd == "Left(左)":
time.sleep(cmd_delay)
press_key('left')
elif cmd == "Right(右)":
time.sleep(cmd_delay)
press_key('right')
elif cmd == "Home":
time.sleep(cmd_delay)
press_key('home')
elif cmd == "End":
time.sleep(cmd_delay)
press_key('end')
elif cmd == "Page Up":
time.sleep(cmd_delay)
press_key('pageup')
elif cmd == "Page Down":
time.sleep(cmd_delay)
press_key('pagedown')
# ── 其他按键 ──
elif cmd == "Insert":
time.sleep(cmd_delay)
press_key('insert')
elif cmd == "Print Screen":
time.sleep(cmd_delay)
press_key('printscreen')
elif cmd == "Scroll Lock":
time.sleep(cmd_delay)
press_key('scrolllock')
elif cmd == "Pause":
time.sleep(cmd_delay)
press_key('pause')
elif cmd == "NumLock":
time.sleep(cmd_delay)
press_key('numlock')
elif cmd == "Numpad Enter":
time.sleep(cmd_delay)
press_key('enter')
# ── 修饰键 ──
elif cmd == "Ctrl":
time.sleep(cmd_delay)
press_key('ctrl')
elif cmd == "Alt":
time.sleep(cmd_delay)
press_key('alt')
elif cmd == "Shift":
time.sleep(cmd_delay)
press_key('shift')
elif cmd == "Win(徽标键)":
time.sleep(cmd_delay)
press_key('win')
# ── 组合键 ──
elif cmd == "Alt+Tab(切换窗口)":
time.sleep(cmd_delay)
if HAS_XDOTOOL:
press_alt_tab_xdotool(max_retries=3)
else:
press_alt_tab_pynput()
elif cmd == "Alt+F4(关闭窗口)":
time.sleep(cmd_delay)
press_hotkey('alt', 'f4')
elif cmd == "Ctrl+C(复制)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'c')
elif cmd == "Ctrl+V(粘贴)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'v')
elif cmd == "Ctrl+X(剪切)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'x')
elif cmd == "Ctrl+A(全选)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'a')
elif cmd == "Ctrl+S(保存)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 's')
elif cmd == "Ctrl+Z(撤销)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'z')
elif cmd == "Ctrl+Y(重做)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'y')
elif cmd == "Ctrl+F(查找)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'f')
elif cmd == "Ctrl+P(打印)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'p')
elif cmd == "Ctrl+N(新建)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'n')
elif cmd == "Ctrl+W(关闭标签)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'w')
elif cmd == "Ctrl+T(新标签)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 't')
elif cmd == "Ctrl+Shift+T(恢复标签)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'shift', 't')
elif cmd == "Alt+Enter(属性)":
time.sleep(cmd_delay)
press_hotkey('alt', 'enter')
elif cmd == "Win+D(显示桌面)":
time.sleep(cmd_delay)
press_hotkey('win', 'd')
elif cmd == "Win+E(资源管理器)":
time.sleep(cmd_delay)
press_hotkey('win', 'e')
elif cmd == "Win+L(锁屏)":
time.sleep(cmd_delay)
press_hotkey('win', 'l')
elif cmd == "Win+R(运行)":
time.sleep(cmd_delay)
press_hotkey('win', 'r')
elif cmd == "Win+Tab(任务视图)":
time.sleep(cmd_delay)
press_hotkey('win', 'tab')
elif cmd == "Shift+Delete(永久删除)":
time.sleep(cmd_delay)
press_hotkey('shift', 'delete')
elif cmd == "Ctrl+Shift+Esc(任务管理器)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'shift', 'esc')
elif cmd == "Alt+Space(窗口菜单)":
time.sleep(cmd_delay)
press_hotkey('alt', 'space')
elif cmd == "Ctrl+Home(文档开头)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'home')
elif cmd == "Ctrl+End(文档结尾)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'end')
elif cmd == "Ctrl+Left(单词左移)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'left')
elif cmd == "Ctrl+Right(单词右移)":
time.sleep(cmd_delay)
press_hotkey('ctrl', 'right')
elif cmd == "Shift+Home(选中到行首)":
time.sleep(cmd_delay)
press_hotkey('shift', 'home')
elif cmd == "Shift+End(选中到行尾)":
time.sleep(cmd_delay)
press_hotkey('shift', 'end')
# ── 特殊命令 ──
elif cmd == "粘贴":
time.sleep(cmd_delay)
pyperclip.copy(mid)
press_hotkey('ctrl', 'v')
elif cmd == "延时":
try:
extra = float(mid)
time.sleep(extra)
except ValueError:
pass
elif cmd == "读取并粘贴":
text = self._read_xlsx_cell()
if text is not None:
time.sleep(cmd_delay)
pyperclip.copy(str(text))
press_hotkey('ctrl', 'v')
def _read_xlsx_cell(self):
try:
import openpyxl
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "openpyxl"])
import openpyxl
base_dir = os.path.dirname(os.path.abspath(__file__))
xlsx_files = [f for f in os.listdir(base_dir) if f.endswith(".xlsx")]
if not xlsx_files:
return None
wb = openpyxl.load_workbook(os.path.join(base_dir, xlsx_files[0]), read_only=True, data_only=True)
ws = wb.worksheets[0]
cells = []
for row in ws.iter_rows(min_col=2, max_col=2, values_only=True):
cells.append(row[0])
wb.close()
start = 0
for i, v in enumerate(cells):
if v is not None and str(v).strip():
start = i
break
target = max(start, self._xlsx_row)
if target >= len(cells):
target = start
while target < len(cells) and (cells[target] is None or str(cells[target]).strip() == ""):
target += 1
if target >= len(cells):
return None
value = cells[target]
self._xlsx_row = target + 1
return value
def _on_close(self):
self._running = False
if self._hotkey_listener:
try:
self._hotkey_listener.stop()
except:
pass
self._clear_modifiers()
self.destroy()
if __name__ == "__main__":
if platform.system() == "Windows":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
try:
print("[INFO] 正在启动循环粘贴工具...")
app = App()
print("[INFO] 程序界面已创建,进入主循环...")
app.mainloop()
print("[INFO] 程序正常退出")
except Exception as e:
print(f"[ERROR] 程序启动失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)