"""
循环粘贴工具 v1.0
通过 Ctrl+B 触发/停止执行命令序列
"""
import tkinter as tk
from tkinter import ttk, messagebox
import json, os, sys, time, threading, platform
import pyperclip
import pyautogui
import keyboard
── 依赖检查 ──────────────────────────────────────────────────────────────────
_missing = []
try: import pyperclip
except ImportError: _missing.append("pyperclip")
try: import pyautogui
except ImportError: _missing.append("pyautogui")
try: import keyboard
except ImportError: _missing.append("keyboard")
if _missing:
import subprocess, sys
subprocess.check_call([sys.executable, "-m", "pip", "install"] + _missing)
import importlib
for m in _missing:
importlib.import_module(m)
import pyperclip, pyautogui, keyboard
── 常量 ──────────────────────────────────────────────────────────────────────
BASE_W, BASE_H = 1920, 1080
SETTINGS_FILE = os.path.join(os.path.dirname(os.path.abspath(file)), "循环粘贴_设置.json")
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 = [
"单击左键","单击右键","双击左键","粘贴","Page Up","Page Down","延时","读取并粘贴"
]
CMD_HELP = (
"命令说明:\n"
" 单击左键 → 中间填 x,y 在该坐标左键单击(留空则在鼠标当前位置点击)\n"
" 单击右键 → 中间填 x,y 在该坐标右键单击(留空则在鼠标当前位置点击)\n"
" 双击左键 → 中间填 x,y 在该坐标左键双击(留空则在鼠标当前位置点击)\n"
" 粘贴 → 中间填文字 先点左键再粘贴内容(右侧备注)\n"
" Page Up → 中间忽略 执行 PageUp 翻页\n"
" Page Down → 中间忽略 执行 PageDown 翻页\n"
" 延时 → 中间填秒数 额外等待指定秒数\n"
" 读取并粘贴→ 从同目录xlsx第2列依次读取并粘贴\n"
"\n"
" 循环次数 → 顶部设置,整个命令序列重复执行的次数(默认1次)"
)
── 主应用 ────────────────────────────────────────────────────────────────────
class App(tk.Tk):
def init(self):
super().init()
self.title("循环粘贴工具")
self._running = False
self._run_thread = None
self._xlsx_row = 0 # xlsx 当前读取行(0-based)
self._drag_x = self._drag_y = 0
分辨率缩放比
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
self.sx = sw / BASE_W
self.sy = sh / BASE_H
加载设置
self.cfg = self._load_cfg()
窗口尺寸/位置
列宽推算:Combobox≈15字符*8px + 中间100字符*8px + 右侧30字符*8px + 滚动条20 + 边距30 ≈ 1230
DEFAULT_WIDTH = 1230
geo = self.cfg.get("geometry", f"{DEFAULT_WIDTH}x800+100+50")
self.geometry(geo)
self.configure(bg=WIN_BG)
self.minsize(600, 400)
self._build_ui()
self._load_rows()
绑定 Ctrl+B
keyboard.add_hotkey("ctrl+b", self._toggle_run)
self.protocol("WM_DELETE_WINDOW", self._on_close)
── 设置持久化 ────────────────────────────────────────────────────────────
def _load_cfg(self):
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_cfg(self):
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(SETTINGS_FILE, "w", encoding="utf-8") as f:
json.dump(self.cfg, f, ensure_ascii=False, indent=2)
── UI 构建 ───────────────────────────────────────────────────────────────
def _build_ui(self):
── 顶部设置区 ────────────────────────────────────────────────────────
top = tk.Frame(self, bg=WIN_BG)
top.pack(fill="x", padx=6, pady=4)
tk.Label(top, text="命令间延时(s):", bg=WIN_BG, font=FONT).pack(side="left")
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.02"))
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_cfg)
save_btn.pack(side="left", padx=4)
Ctrl+B 提示
tk.Label(top, text=" 【Ctrl+B】启动/停止执行", bg=WIN_BG,
font=(FONT_NAME, 12, "bold"), fg="#E53935").pack(side="left", padx=10)
状态标签
self.lbl_status = tk.Label(top, text="●停止", bg=WIN_BG,
font=(FONT_NAME, 12, "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=15, 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 = [] # 每项: {frame, cmd, mid, rem}
── 行管理 ────────────────────────────────────────────────────────────────
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=14, font=FONT, state="normal")
cmb.pack(side="left", padx=1, pady=1)
中间输入框:宽100,无边框
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_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_after(self, target_rd):
idx = self._row_index(target_rd)
new_rd = self._make_row(idx + 1)
把 frame 移到正确位置
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):
saved = self.cfg.get("rows", [])
self._xlsx_row = self.cfg.get("xlsx_row", 0)
count = max(DEFAULT_ROWS, len(saved))
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("rem",""))
self.rows.append(rd)
── 执行引擎 ──────────────────────────────────────────────────────────────
def _toggle_run(self):
if self._running:
self._running = False
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.02
try:
loop_count = max(1, int(self.var_loop_count.get()))
except ValueError:
loop_count = 1
pyautogui.FAILSAFE = False
for _loop in range(loop_count):
if not self._running:
break
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.after(0, lambda: self.lbl_status.configure(text="●完成", fg="#1565C0"))
def _exec_one(self, cmd, mid, cmd_delay):
def parse_xy(s):
"""解析坐标字符串,返回 (x, y);若为空则返回 None(表示使用鼠标当前位置)"""
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:
pyautogui.click(xy[0], xy[1], button="left")
else:
pyautogui.click(button="left")
elif cmd in ("单击右键",):
xy = parse_xy(mid)
time.sleep(cmd_delay)
if xy:
pyautogui.click(xy[0], xy[1], button="right")
else:
pyautogui.click(button="right")
elif cmd in ("双击左键",):
xy = parse_xy(mid)
time.sleep(cmd_delay)
if xy:
pyautogui.doubleClick(xy[0], xy[1])
else:
pyautogui.doubleClick()
elif cmd == "粘贴":
time.sleep(cmd_delay)
pyperclip.copy(mid)
pyautogui.hotkey("ctrl", "v")
elif cmd == "Page Up":
time.sleep(cmd_delay)
pyautogui.press("pageup")
elif cmd == "Page Down":
time.sleep(cmd_delay)
pyautogui.press("pagedown")
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))
pyautogui.hotkey("ctrl", "v")
def _read_xlsx_cell(self):
"""读取同目录 xlsx 第一个工作表第一列,从上次记录行的下一行读取"""
try:
import openpyxl
except ImportError:
import subprocess, sys
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
self._save_cfg()
try:
keyboard.remove_hotkey("ctrl+b")
except Exception:
pass
self.destroy()
── 入口 ──────────────────────────────────────────────────────────────────────
if name == "main":
Windows 高 DPI 修正
if platform.system() == "Windows":
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
app = App()
app.mainloop()