PyClickerRPA 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autoclicker/__init__.py +14 -0
- autoclicker/app.py +21 -0
- autoclicker/capture.py +94 -0
- autoclicker/clicker.py +212 -0
- autoclicker/dpi.py +30 -0
- autoclicker/gui/__init__.py +1 -0
- autoclicker/gui/flow_connection.py +91 -0
- autoclicker/gui/flow_editor.py +340 -0
- autoclicker/gui/flow_node.py +107 -0
- autoclicker/gui/hotkeys.py +40 -0
- autoclicker/gui/main_window.py +651 -0
- autoclicker/gui/monitor.py +84 -0
- autoclicker/gui/preview_label.py +176 -0
- autoclicker/gui/qt_runner.py +71 -0
- autoclicker/gui/recorder.py +163 -0
- autoclicker/gui/scheduler_widget.py +160 -0
- autoclicker/gui/snippet_library.py +117 -0
- autoclicker/gui/task_widget.py +547 -0
- autoclicker/matcher.py +152 -0
- autoclicker/models.py +468 -0
- autoclicker/ocr.py +63 -0
- autoclicker/run.py +64 -0
- autoclicker/runner.py +666 -0
- autoclicker/windows.py +53 -0
- pyclickerrpa-0.1.0.dist-info/METADATA +90 -0
- pyclickerrpa-0.1.0.dist-info/RECORD +29 -0
- pyclickerrpa-0.1.0.dist-info/WHEEL +5 -0
- pyclickerrpa-0.1.0.dist-info/entry_points.txt +5 -0
- pyclickerrpa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Main window — port of mainwindow.cpp (Phase 2: toolbar + list editor + preview + log).
|
|
2
|
+
|
|
3
|
+
The flow-graph editor tab arrives in Phase 3; this window already offers the
|
|
4
|
+
linear list editor, window targeting, capture preview, run/stop/pause wired to
|
|
5
|
+
the engine, and JSON save/load.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
import zipfile
|
|
14
|
+
|
|
15
|
+
from PySide6.QtCore import Qt
|
|
16
|
+
from PySide6.QtGui import QAction, QGuiApplication, QKeySequence
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QComboBox, QDockWidget, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
|
|
19
|
+
QMainWindow, QMessageBox, QPushButton, QSplitter, QTableWidget,
|
|
20
|
+
QTableWidgetItem, QTabWidget, QTextEdit, QVBoxLayout, QWidget,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from autoclicker import capture as capture_mod
|
|
24
|
+
from autoclicker import windows as windows_mod
|
|
25
|
+
from autoclicker.models import Step, StepType, Task
|
|
26
|
+
from autoclicker.gui.preview_label import PreviewLabel, PickMode
|
|
27
|
+
from autoclicker.gui.qt_runner import QtRunner
|
|
28
|
+
from autoclicker.gui.task_widget import TaskWidget
|
|
29
|
+
from autoclicker.gui.flow_editor import FlowEditor
|
|
30
|
+
from autoclicker.gui.recorder import Recorder
|
|
31
|
+
from autoclicker.gui.scheduler_widget import SchedulerWidget
|
|
32
|
+
from autoclicker.gui.snippet_library import SnippetLibrary
|
|
33
|
+
from autoclicker.gui.hotkeys import HotkeyManager
|
|
34
|
+
from autoclicker.gui.monitor import MonitorController
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MainWindow(QMainWindow):
|
|
38
|
+
def __init__(self):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.setWindowTitle("AutoClicker (Python)")
|
|
41
|
+
self.resize(1100, 720)
|
|
42
|
+
self._windows: list[windows_mod.WindowInfo] = []
|
|
43
|
+
self._last_capture = None
|
|
44
|
+
self._save_path = ""
|
|
45
|
+
|
|
46
|
+
self._runner = QtRunner(self)
|
|
47
|
+
self._runner.logMessage.connect(self._append_log)
|
|
48
|
+
self._runner.stepStarted.connect(self._on_step_started)
|
|
49
|
+
self._runner.matchFound.connect(
|
|
50
|
+
lambda x, y, c: self._append_log(f" → 命中 ({x},{y}) {c:.3f}"))
|
|
51
|
+
self._runner.runFinished.connect(self._on_finished)
|
|
52
|
+
|
|
53
|
+
self._recorder = Recorder(self)
|
|
54
|
+
self._recorder.stepRecorded.connect(self._on_step_recorded)
|
|
55
|
+
|
|
56
|
+
self._build_ui()
|
|
57
|
+
self._build_menu()
|
|
58
|
+
|
|
59
|
+
self._pick_index = -1 # step index awaiting coord pick from preview
|
|
60
|
+
|
|
61
|
+
self.scheduler.runTaskRequested.connect(self._run_scheduled)
|
|
62
|
+
self.snippets.insertRequested.connect(self._on_snippet_insert)
|
|
63
|
+
self.preview.regionSelected.connect(self._on_region_selected)
|
|
64
|
+
self.preview.colorPicked.connect(self._on_color_picked)
|
|
65
|
+
self.preview.pointPicked.connect(self._on_point_picked)
|
|
66
|
+
self.preview.twoPointsPicked.connect(self._on_two_points_picked)
|
|
67
|
+
self._runner.matchFound.connect(lambda x, y, c: self.preview.add_marker(x, y))
|
|
68
|
+
self._runner.varChanged.connect(self._on_var_changed)
|
|
69
|
+
self.task_widget.testStepRequested.connect(self._test_single_step)
|
|
70
|
+
|
|
71
|
+
self._hotkeys = HotkeyManager(self)
|
|
72
|
+
self._hotkeys.toggleRunRequested.connect(self._hotkey_toggle)
|
|
73
|
+
self._hotkeys.stopRequested.connect(self.stop_task)
|
|
74
|
+
self._hotkeys.start()
|
|
75
|
+
|
|
76
|
+
self._really_quit = False
|
|
77
|
+
self._monitor = MonitorController(self)
|
|
78
|
+
self._monitor.triggered.connect(self._on_monitor_trigger)
|
|
79
|
+
self._monitor.status.connect(self._append_log)
|
|
80
|
+
self._build_tray()
|
|
81
|
+
|
|
82
|
+
self.refresh_windows()
|
|
83
|
+
|
|
84
|
+
def _hotkey_toggle(self):
|
|
85
|
+
if self._runner.is_running():
|
|
86
|
+
self.stop_task()
|
|
87
|
+
else:
|
|
88
|
+
self.run_task()
|
|
89
|
+
|
|
90
|
+
def _test_single_step(self, step):
|
|
91
|
+
if self._runner.is_running():
|
|
92
|
+
self._append_log("正在运行中,无法试跑单步")
|
|
93
|
+
return
|
|
94
|
+
self.log.clear()
|
|
95
|
+
self._clear_vars()
|
|
96
|
+
self.preview.clear_markers()
|
|
97
|
+
self._set_running(True)
|
|
98
|
+
self._append_log("试跑单步…")
|
|
99
|
+
self._runner.start(Task(steps=[step]), target_hwnd=self._target_hwnd())
|
|
100
|
+
|
|
101
|
+
# ---- system tray ----
|
|
102
|
+
def _build_tray(self):
|
|
103
|
+
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QStyle
|
|
104
|
+
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
105
|
+
self._tray = None
|
|
106
|
+
return
|
|
107
|
+
icon = self.style().standardIcon(QStyle.SP_ComputerIcon)
|
|
108
|
+
self.setWindowIcon(icon)
|
|
109
|
+
self._tray = QSystemTrayIcon(icon, self)
|
|
110
|
+
self._tray.setToolTip("AutoClicker")
|
|
111
|
+
menu = QMenu()
|
|
112
|
+
menu.addAction("显示主窗口", self._show_normal)
|
|
113
|
+
menu.addAction("停止监视", lambda: self._monitor.stop())
|
|
114
|
+
menu.addSeparator()
|
|
115
|
+
menu.addAction("退出", self._quit)
|
|
116
|
+
self._tray.setContextMenu(menu)
|
|
117
|
+
self._tray.activated.connect(
|
|
118
|
+
lambda reason: self._show_normal() if reason == QSystemTrayIcon.DoubleClick else None)
|
|
119
|
+
self._tray.show()
|
|
120
|
+
|
|
121
|
+
def _show_normal(self):
|
|
122
|
+
self.showNormal()
|
|
123
|
+
self.activateWindow()
|
|
124
|
+
|
|
125
|
+
def _quit(self):
|
|
126
|
+
self._really_quit = True
|
|
127
|
+
self.close()
|
|
128
|
+
|
|
129
|
+
# ---- monitor / trigger ----
|
|
130
|
+
def monitor_configure(self):
|
|
131
|
+
kind, ok = QInputDialog.getItem(self, "监视条件", "类型:", ["图片出现", "颜色匹配"], 0, False)
|
|
132
|
+
if not ok:
|
|
133
|
+
return
|
|
134
|
+
interval, ok = QInputDialog.getInt(self, "轮询间隔", "毫秒:", 1000, 100, 600000, 100)
|
|
135
|
+
if not ok:
|
|
136
|
+
return
|
|
137
|
+
cond = {"interval_ms": interval}
|
|
138
|
+
if kind == "图片出现":
|
|
139
|
+
path, _ = QFileDialog.getOpenFileName(self, "选择模板图片", "", "图片 (*.png *.jpg *.jpeg *.bmp)")
|
|
140
|
+
if not path:
|
|
141
|
+
return
|
|
142
|
+
thr, ok = QInputDialog.getDouble(self, "匹配阈值", "0~1:", 0.8, 0.0, 1.0, 2)
|
|
143
|
+
if not ok:
|
|
144
|
+
return
|
|
145
|
+
cond.update(type="image", image=path, threshold=thr)
|
|
146
|
+
else:
|
|
147
|
+
color, ok = QInputDialog.getText(self, "目标颜色", "#RRGGBB:")
|
|
148
|
+
if not ok or not color:
|
|
149
|
+
return
|
|
150
|
+
x, ok = QInputDialog.getInt(self, "X", "像素 X:", 0, 0, 100000)
|
|
151
|
+
if not ok:
|
|
152
|
+
return
|
|
153
|
+
y, ok = QInputDialog.getInt(self, "Y", "像素 Y:", 0, 0, 100000)
|
|
154
|
+
if not ok:
|
|
155
|
+
return
|
|
156
|
+
tol, ok = QInputDialog.getInt(self, "容差", "0~255:", 30, 0, 255)
|
|
157
|
+
if not ok:
|
|
158
|
+
return
|
|
159
|
+
cond.update(type="color", color=color, x=x, y=y, tolerance=tol)
|
|
160
|
+
self._monitor.start(self._target_hwnd(), cond)
|
|
161
|
+
self._append_log(f"开始监视: {cond}")
|
|
162
|
+
|
|
163
|
+
def _on_monitor_trigger(self):
|
|
164
|
+
self._append_log("监视触发!")
|
|
165
|
+
if self._runner.is_running():
|
|
166
|
+
return
|
|
167
|
+
task = self._current_task()
|
|
168
|
+
if not task.steps:
|
|
169
|
+
return
|
|
170
|
+
self._clear_vars()
|
|
171
|
+
self.preview.clear_markers()
|
|
172
|
+
self._set_running(True)
|
|
173
|
+
self._runner.start(task, target_hwnd=self._target_hwnd(),
|
|
174
|
+
screenshot_on_fail=True, screenshot_dir=".failures")
|
|
175
|
+
|
|
176
|
+
def closeEvent(self, event):
|
|
177
|
+
# Minimize to tray instead of quitting, unless explicitly quitting
|
|
178
|
+
if getattr(self, "_tray", None) is not None and not self._really_quit:
|
|
179
|
+
event.ignore()
|
|
180
|
+
self.hide()
|
|
181
|
+
from PySide6.QtWidgets import QSystemTrayIcon
|
|
182
|
+
self._tray.showMessage("AutoClicker", "已最小化到托盘,监视仍在后台运行",
|
|
183
|
+
QSystemTrayIcon.MessageIcon.Information, 2000)
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
self._hotkeys.stop()
|
|
187
|
+
self._monitor.stop()
|
|
188
|
+
if self._recorder.is_recording():
|
|
189
|
+
self._recorder.stop()
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
super().closeEvent(event)
|
|
193
|
+
|
|
194
|
+
# ---- UI ----
|
|
195
|
+
def _build_ui(self):
|
|
196
|
+
central = QWidget()
|
|
197
|
+
self.setCentralWidget(central)
|
|
198
|
+
root = QVBoxLayout(central)
|
|
199
|
+
|
|
200
|
+
bar = QHBoxLayout()
|
|
201
|
+
self.window_combo = QComboBox()
|
|
202
|
+
self.window_combo.setMinimumWidth(280)
|
|
203
|
+
refresh_btn = QPushButton("刷新")
|
|
204
|
+
refresh_btn.clicked.connect(self.refresh_windows)
|
|
205
|
+
capture_btn = QPushButton("截图")
|
|
206
|
+
capture_btn.clicked.connect(self.capture)
|
|
207
|
+
self.record_btn = QPushButton("● 录制")
|
|
208
|
+
self.record_btn.clicked.connect(self.toggle_record)
|
|
209
|
+
self.run_btn = QPushButton("▶ 运行")
|
|
210
|
+
self.run_btn.clicked.connect(self.run_task)
|
|
211
|
+
self.pause_btn = QPushButton("⏸ 暂停")
|
|
212
|
+
self.pause_btn.clicked.connect(self.toggle_pause)
|
|
213
|
+
self.pause_btn.setEnabled(False)
|
|
214
|
+
self.stop_btn = QPushButton("■ 停止")
|
|
215
|
+
self.stop_btn.clicked.connect(self.stop_task)
|
|
216
|
+
self.stop_btn.setEnabled(False)
|
|
217
|
+
bar.addWidget(QLabel("目标窗口"))
|
|
218
|
+
bar.addWidget(self.window_combo, 1)
|
|
219
|
+
bar.addWidget(refresh_btn)
|
|
220
|
+
bar.addWidget(capture_btn)
|
|
221
|
+
bar.addStretch()
|
|
222
|
+
bar.addWidget(self.record_btn)
|
|
223
|
+
bar.addWidget(self.run_btn)
|
|
224
|
+
bar.addWidget(self.pause_btn)
|
|
225
|
+
bar.addWidget(self.stop_btn)
|
|
226
|
+
root.addLayout(bar)
|
|
227
|
+
|
|
228
|
+
splitter = QSplitter(Qt.Horizontal)
|
|
229
|
+
self.tabs = QTabWidget()
|
|
230
|
+
self.flow_editor = FlowEditor()
|
|
231
|
+
self.task_widget = TaskWidget()
|
|
232
|
+
self.scheduler = SchedulerWidget()
|
|
233
|
+
self.snippets = SnippetLibrary()
|
|
234
|
+
self.tabs.addTab(self.flow_editor, "流程图")
|
|
235
|
+
self.tabs.addTab(self.task_widget, "步骤列表")
|
|
236
|
+
self.tabs.addTab(self.scheduler, "定时")
|
|
237
|
+
self.tabs.addTab(self.snippets, "片段")
|
|
238
|
+
splitter.addWidget(self.tabs)
|
|
239
|
+
|
|
240
|
+
# Right pane: preview + preview tools
|
|
241
|
+
right = QWidget()
|
|
242
|
+
rlay = QVBoxLayout(right)
|
|
243
|
+
rlay.setContentsMargins(0, 0, 0, 0)
|
|
244
|
+
ptools = QHBoxLayout()
|
|
245
|
+
for text, slot in [("裁剪模板", self.crop_template), ("取色", self.pick_color),
|
|
246
|
+
("取坐标→选中步骤", self.pick_coord_for_step), ("OCR叠加", self.ocr_overlay)]:
|
|
247
|
+
b = QPushButton(text)
|
|
248
|
+
b.clicked.connect(slot)
|
|
249
|
+
ptools.addWidget(b)
|
|
250
|
+
ptools.addStretch()
|
|
251
|
+
rlay.addLayout(ptools)
|
|
252
|
+
self.preview = PreviewLabel()
|
|
253
|
+
rlay.addWidget(self.preview, 1)
|
|
254
|
+
splitter.addWidget(right)
|
|
255
|
+
splitter.setSizes([560, 540])
|
|
256
|
+
root.addWidget(splitter, 1)
|
|
257
|
+
|
|
258
|
+
self.log = QTextEdit()
|
|
259
|
+
self.log.setReadOnly(True)
|
|
260
|
+
self.log.setMaximumHeight(180)
|
|
261
|
+
root.addWidget(self.log)
|
|
262
|
+
|
|
263
|
+
# Live variables dock (updated from runner varChanged)
|
|
264
|
+
self.var_table = QTableWidget(0, 2)
|
|
265
|
+
self.var_table.setHorizontalHeaderLabels(["变量", "值"])
|
|
266
|
+
self.var_table.horizontalHeader().setStretchLastSection(True)
|
|
267
|
+
self._var_rows: dict[str, int] = {}
|
|
268
|
+
dock = QDockWidget("变量", self)
|
|
269
|
+
dock.setWidget(self.var_table)
|
|
270
|
+
self.addDockWidget(Qt.RightDockWidgetArea, dock)
|
|
271
|
+
|
|
272
|
+
def _on_var_changed(self, name: str, value: str):
|
|
273
|
+
row = self._var_rows.get(name)
|
|
274
|
+
if row is None:
|
|
275
|
+
row = self.var_table.rowCount()
|
|
276
|
+
self.var_table.insertRow(row)
|
|
277
|
+
self.var_table.setItem(row, 0, QTableWidgetItem(name))
|
|
278
|
+
self._var_rows[name] = row
|
|
279
|
+
self.var_table.setItem(row, 1, QTableWidgetItem(value))
|
|
280
|
+
|
|
281
|
+
def _clear_vars(self):
|
|
282
|
+
self.var_table.setRowCount(0)
|
|
283
|
+
self._var_rows.clear()
|
|
284
|
+
|
|
285
|
+
def _build_menu(self):
|
|
286
|
+
m = self.menuBar().addMenu("文件")
|
|
287
|
+
save = QAction("保存", self, shortcut=QKeySequence.Save, triggered=self.save_task)
|
|
288
|
+
save_as = QAction("另存为…", self, triggered=lambda: self.save_task(force_dialog=True))
|
|
289
|
+
open_ = QAction("打开", self, shortcut=QKeySequence.Open, triggered=self.open_task)
|
|
290
|
+
m.addAction(save)
|
|
291
|
+
m.addAction(save_as)
|
|
292
|
+
m.addAction(open_)
|
|
293
|
+
m.addSeparator()
|
|
294
|
+
m.addAction(QAction("导出任务包(.zip)…", self, triggered=self.export_package))
|
|
295
|
+
m.addAction(QAction("导入任务包(.zip)…", self, triggered=self.import_package))
|
|
296
|
+
|
|
297
|
+
e = self.menuBar().addMenu("编辑")
|
|
298
|
+
e.addAction(QAction("选中步骤存为片段…", self, triggered=self.save_snippet))
|
|
299
|
+
e.addSeparator()
|
|
300
|
+
e.addAction(QAction("复制步骤到剪贴板(JSON)", self, triggered=self.copy_steps_json))
|
|
301
|
+
e.addAction(QAction("从剪贴板粘贴步骤(JSON)", self, triggered=self.paste_steps_json))
|
|
302
|
+
|
|
303
|
+
mon = self.menuBar().addMenu("监视")
|
|
304
|
+
mon.addAction(QAction("配置并开始监视…", self, triggered=self.monitor_configure))
|
|
305
|
+
mon.addAction(QAction("停止监视", self, triggered=lambda: self._monitor.stop()))
|
|
306
|
+
|
|
307
|
+
# ---- windows / capture ----
|
|
308
|
+
def refresh_windows(self):
|
|
309
|
+
self._windows = windows_mod.list_windows()
|
|
310
|
+
self.window_combo.clear()
|
|
311
|
+
self.window_combo.addItem("(整个屏幕)", None)
|
|
312
|
+
for info in self._windows:
|
|
313
|
+
self.window_combo.addItem(f"{info.title} [{info.width}x{info.height}]", info)
|
|
314
|
+
|
|
315
|
+
def _target_hwnd(self):
|
|
316
|
+
info = self.window_combo.currentData()
|
|
317
|
+
return info.hwnd if info else None
|
|
318
|
+
|
|
319
|
+
def _current_task(self) -> Task:
|
|
320
|
+
"""Task from the active editor tab (flow graph or linear list)."""
|
|
321
|
+
if self.tabs.currentWidget() is self.flow_editor:
|
|
322
|
+
return self.flow_editor.build_task()
|
|
323
|
+
return self.task_widget.get_task()
|
|
324
|
+
|
|
325
|
+
def capture(self):
|
|
326
|
+
try:
|
|
327
|
+
hwnd = self._target_hwnd()
|
|
328
|
+
img = capture_mod.capture_window(hwnd) if hwnd else capture_mod.capture_screen()
|
|
329
|
+
if img is None or img.size == 0:
|
|
330
|
+
self._append_log("截图为空(窗口可能最小化或被遮挡)")
|
|
331
|
+
return
|
|
332
|
+
self._last_capture = img
|
|
333
|
+
self.preview.set_image_ndarray(img)
|
|
334
|
+
self._append_log(f"截图: {img.shape[1]}x{img.shape[0]}")
|
|
335
|
+
except Exception as e:
|
|
336
|
+
self._append_log(f"截图失败: {e}")
|
|
337
|
+
|
|
338
|
+
# ---- run control ----
|
|
339
|
+
def run_task(self):
|
|
340
|
+
if self._runner.is_running():
|
|
341
|
+
return
|
|
342
|
+
task = self._current_task()
|
|
343
|
+
if not task.steps:
|
|
344
|
+
self._append_log("任务为空")
|
|
345
|
+
return
|
|
346
|
+
self.log.clear()
|
|
347
|
+
self._clear_vars()
|
|
348
|
+
self.preview.clear_markers()
|
|
349
|
+
self._set_running(True)
|
|
350
|
+
self._runner.start(task, target_hwnd=self._target_hwnd(),
|
|
351
|
+
screenshot_on_fail=True, screenshot_dir=".failures")
|
|
352
|
+
|
|
353
|
+
def stop_task(self):
|
|
354
|
+
self._runner.stop()
|
|
355
|
+
|
|
356
|
+
def toggle_pause(self):
|
|
357
|
+
if self.pause_btn.text().startswith("⏸"):
|
|
358
|
+
self._runner.pause()
|
|
359
|
+
self.pause_btn.setText("▶ 继续")
|
|
360
|
+
else:
|
|
361
|
+
self._runner.resume()
|
|
362
|
+
self.pause_btn.setText("⏸ 暂停")
|
|
363
|
+
|
|
364
|
+
def _set_running(self, running: bool):
|
|
365
|
+
self.run_btn.setEnabled(not running)
|
|
366
|
+
self.stop_btn.setEnabled(running)
|
|
367
|
+
self.pause_btn.setEnabled(running)
|
|
368
|
+
if not running:
|
|
369
|
+
self.pause_btn.setText("⏸ 暂停")
|
|
370
|
+
|
|
371
|
+
def _on_step_started(self, index: int, desc: str):
|
|
372
|
+
self._append_log(f"[{index + 1}] {desc}")
|
|
373
|
+
|
|
374
|
+
def _on_finished(self):
|
|
375
|
+
self._set_running(False)
|
|
376
|
+
|
|
377
|
+
def _append_log(self, msg: str):
|
|
378
|
+
self.log.append(msg)
|
|
379
|
+
|
|
380
|
+
# ---- save / load ----
|
|
381
|
+
def save_task(self, force_dialog: bool = False):
|
|
382
|
+
path = self._save_path
|
|
383
|
+
if force_dialog or not path:
|
|
384
|
+
path, _ = QFileDialog.getSaveFileName(self, "保存任务", "", "任务文件 (*.json)")
|
|
385
|
+
if not path:
|
|
386
|
+
return
|
|
387
|
+
if not path.lower().endswith(".json"):
|
|
388
|
+
path += ".json"
|
|
389
|
+
if self._current_task().save_to_file(path):
|
|
390
|
+
self._save_path = path
|
|
391
|
+
self._append_log(f"已保存: {path}")
|
|
392
|
+
else:
|
|
393
|
+
QMessageBox.warning(self, "保存失败", f"无法写入 {path}")
|
|
394
|
+
|
|
395
|
+
def open_task(self):
|
|
396
|
+
path, _ = QFileDialog.getOpenFileName(self, "打开任务", "", "任务文件 (*.json)")
|
|
397
|
+
if not path:
|
|
398
|
+
return
|
|
399
|
+
try:
|
|
400
|
+
task = Task.load_from_file(path)
|
|
401
|
+
except Exception as e:
|
|
402
|
+
QMessageBox.warning(self, "打开失败", str(e))
|
|
403
|
+
return
|
|
404
|
+
self._load_task_into_editors(task, path)
|
|
405
|
+
|
|
406
|
+
def _load_task_into_editors(self, task: Task, path: str = ""):
|
|
407
|
+
self.task_widget.set_task(task)
|
|
408
|
+
self.flow_editor.load_task(task)
|
|
409
|
+
if path:
|
|
410
|
+
self._save_path = path
|
|
411
|
+
self.setWindowTitle(f"AutoClicker (Python) — {os.path.basename(path)}")
|
|
412
|
+
self._append_log(f"已加载: {path} ({len(task.steps)} 步)")
|
|
413
|
+
|
|
414
|
+
# ---- recorder ----
|
|
415
|
+
def toggle_record(self):
|
|
416
|
+
if self._recorder.is_recording():
|
|
417
|
+
self._recorder.stop()
|
|
418
|
+
self.record_btn.setText("● 录制")
|
|
419
|
+
self._append_log("录制结束")
|
|
420
|
+
return
|
|
421
|
+
hwnd = self._target_hwnd()
|
|
422
|
+
self._recorder.start(hwnd)
|
|
423
|
+
self.record_btn.setText("■ 停止录制")
|
|
424
|
+
self.tabs.setCurrentWidget(self.task_widget)
|
|
425
|
+
self._append_log("开始录制(在目标窗口内操作;再次点击结束)")
|
|
426
|
+
|
|
427
|
+
def _on_step_recorded(self, step: Step):
|
|
428
|
+
self.task_widget.append_step(step)
|
|
429
|
+
|
|
430
|
+
# ---- scheduler / snippets ----
|
|
431
|
+
def _run_scheduled(self, path: str):
|
|
432
|
+
if self._runner.is_running():
|
|
433
|
+
self._append_log(f"定时触发但任务运行中,跳过: {path}")
|
|
434
|
+
return
|
|
435
|
+
try:
|
|
436
|
+
task = Task.load_from_file(path)
|
|
437
|
+
except Exception as e:
|
|
438
|
+
self._append_log(f"定时任务加载失败 {path}: {e}")
|
|
439
|
+
return
|
|
440
|
+
if not task.steps:
|
|
441
|
+
return
|
|
442
|
+
self._append_log(f"定时触发: {path}")
|
|
443
|
+
self._clear_vars()
|
|
444
|
+
self.preview.clear_markers()
|
|
445
|
+
self._set_running(True)
|
|
446
|
+
self._runner.start(task, target_hwnd=self._target_hwnd(),
|
|
447
|
+
screenshot_on_fail=True, screenshot_dir=".failures")
|
|
448
|
+
|
|
449
|
+
def _on_snippet_insert(self, steps):
|
|
450
|
+
self.task_widget.insert_steps(list(steps))
|
|
451
|
+
self.tabs.setCurrentWidget(self.task_widget)
|
|
452
|
+
self._append_log(f"插入片段: {len(steps)} 步")
|
|
453
|
+
|
|
454
|
+
def save_snippet(self):
|
|
455
|
+
steps = self.task_widget.get_selected_steps()
|
|
456
|
+
if not steps:
|
|
457
|
+
self._append_log("请先在「步骤列表」中选中要保存的步骤")
|
|
458
|
+
return
|
|
459
|
+
name, ok = QInputDialog.getText(self, "存为片段", "片段名称:")
|
|
460
|
+
if ok and name:
|
|
461
|
+
self.snippets.save_snippet(name, steps)
|
|
462
|
+
self._append_log(f"已保存片段: {name} ({len(steps)} 步)")
|
|
463
|
+
|
|
464
|
+
# ---- step clipboard (JSON) ----
|
|
465
|
+
def copy_steps_json(self):
|
|
466
|
+
import json
|
|
467
|
+
steps = self.task_widget.get_selected_steps()
|
|
468
|
+
if not steps:
|
|
469
|
+
self._append_log("请先在「步骤列表」中选中要复制的步骤")
|
|
470
|
+
return
|
|
471
|
+
QGuiApplication.clipboard().setText(json.dumps([s.to_json() for s in steps], ensure_ascii=False, indent=2))
|
|
472
|
+
self._append_log(f"已复制 {len(steps)} 步到剪贴板(JSON)")
|
|
473
|
+
|
|
474
|
+
def paste_steps_json(self):
|
|
475
|
+
import json
|
|
476
|
+
text = QGuiApplication.clipboard().text()
|
|
477
|
+
try:
|
|
478
|
+
data = json.loads(text)
|
|
479
|
+
assert isinstance(data, list)
|
|
480
|
+
except (json.JSONDecodeError, AssertionError):
|
|
481
|
+
self._append_log("剪贴板内容不是有效的步骤 JSON 数组")
|
|
482
|
+
return
|
|
483
|
+
steps = [Step.from_json(o) for o in data]
|
|
484
|
+
if not steps:
|
|
485
|
+
return
|
|
486
|
+
self.task_widget.insert_steps(steps)
|
|
487
|
+
self.tabs.setCurrentWidget(self.task_widget)
|
|
488
|
+
self._append_log(f"已从剪贴板粘贴 {len(steps)} 步")
|
|
489
|
+
|
|
490
|
+
# ---- preview picking ----
|
|
491
|
+
_SINGLE_POINT_TYPES = {StepType.ClickPosition, StepType.LongPress, StepType.Scroll,
|
|
492
|
+
StepType.IfColorMatch, StepType.Pinch}
|
|
493
|
+
|
|
494
|
+
def crop_template(self):
|
|
495
|
+
if not self.preview.has_image():
|
|
496
|
+
self._append_log("请先截图")
|
|
497
|
+
return
|
|
498
|
+
self._pick_index = -1
|
|
499
|
+
self.preview.set_pick_mode(PickMode.REGION)
|
|
500
|
+
self._append_log("在预览上框选模板区域…")
|
|
501
|
+
|
|
502
|
+
def pick_color(self):
|
|
503
|
+
if not self.preview.has_image():
|
|
504
|
+
self._append_log("请先截图")
|
|
505
|
+
return
|
|
506
|
+
self._pick_index = -1
|
|
507
|
+
self.preview.set_pick_mode(PickMode.COLOR)
|
|
508
|
+
self._append_log("在预览上点击取色…")
|
|
509
|
+
|
|
510
|
+
def pick_coord_for_step(self):
|
|
511
|
+
if not self.preview.has_image():
|
|
512
|
+
self._append_log("请先截图")
|
|
513
|
+
return
|
|
514
|
+
i = self.task_widget.current_index()
|
|
515
|
+
step = self.task_widget.step_at(i)
|
|
516
|
+
if step is None:
|
|
517
|
+
self._append_log("请先在「步骤列表」选中一个坐标类步骤")
|
|
518
|
+
return
|
|
519
|
+
if step.type == StepType.Drag:
|
|
520
|
+
self._pick_index = i
|
|
521
|
+
self.preview.set_pick_mode(PickMode.TWO_POINTS)
|
|
522
|
+
self._append_log("在预览上依次点击 起点、终点…")
|
|
523
|
+
elif step.type in self._SINGLE_POINT_TYPES:
|
|
524
|
+
self._pick_index = i
|
|
525
|
+
self.preview.set_pick_mode(PickMode.POINT)
|
|
526
|
+
self._append_log(f"在预览上点击以设置「{step.display_name()}」的坐标…")
|
|
527
|
+
else:
|
|
528
|
+
self._append_log("该步骤类型不支持坐标拾取")
|
|
529
|
+
|
|
530
|
+
def _on_point_picked(self, pt):
|
|
531
|
+
if self._pick_index < 0:
|
|
532
|
+
return
|
|
533
|
+
step = self.task_widget.step_at(self._pick_index)
|
|
534
|
+
if step is not None:
|
|
535
|
+
step.x, step.y = pt.x(), pt.y()
|
|
536
|
+
self.task_widget.replace_step(self._pick_index, step)
|
|
537
|
+
self._append_log(f"已设置坐标 ({pt.x()}, {pt.y()})")
|
|
538
|
+
self._pick_index = -1
|
|
539
|
+
|
|
540
|
+
def _on_two_points_picked(self, a, b):
|
|
541
|
+
if self._pick_index < 0:
|
|
542
|
+
return
|
|
543
|
+
step = self.task_widget.step_at(self._pick_index)
|
|
544
|
+
if step is not None:
|
|
545
|
+
step.x, step.y, step.x2, step.y2 = a.x(), a.y(), b.x(), b.y()
|
|
546
|
+
self.task_widget.replace_step(self._pick_index, step)
|
|
547
|
+
self._append_log(f"已设置拖拽 ({a.x()},{a.y()}) → ({b.x()},{b.y()})")
|
|
548
|
+
self._pick_index = -1
|
|
549
|
+
|
|
550
|
+
def _on_region_selected(self, rect):
|
|
551
|
+
if self._last_capture is None:
|
|
552
|
+
return
|
|
553
|
+
import cv2
|
|
554
|
+
x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height()
|
|
555
|
+
crop = self._last_capture[y:y + h, x:x + w]
|
|
556
|
+
if crop.size == 0:
|
|
557
|
+
return
|
|
558
|
+
d = os.path.join(os.getcwd(), ".templates")
|
|
559
|
+
os.makedirs(d, exist_ok=True)
|
|
560
|
+
path = os.path.join(d, f"tmpl_{uuid.uuid4().hex[:8]}.png")
|
|
561
|
+
cv2.imwrite(path, crop)
|
|
562
|
+
QGuiApplication.clipboard().setText(path)
|
|
563
|
+
self._append_log(f"已保存模板: {path} (路径已复制到剪贴板)")
|
|
564
|
+
|
|
565
|
+
def _on_color_picked(self, pt, color):
|
|
566
|
+
hexstr = color.name() # #rrggbb
|
|
567
|
+
QGuiApplication.clipboard().setText(hexstr)
|
|
568
|
+
self._append_log(f"取色 ({pt.x()},{pt.y()}) = {hexstr} (已复制到剪贴板)")
|
|
569
|
+
|
|
570
|
+
def ocr_overlay(self):
|
|
571
|
+
if self._last_capture is None:
|
|
572
|
+
self._append_log("请先截图")
|
|
573
|
+
return
|
|
574
|
+
from autoclicker import matcher
|
|
575
|
+
try:
|
|
576
|
+
results = matcher.ocr_all(self._last_capture)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
self._append_log(f"OCR 失败: {e}")
|
|
579
|
+
return
|
|
580
|
+
if not results:
|
|
581
|
+
self._append_log("OCR: 未识别到文字(或 PaddleOCR 未安装)")
|
|
582
|
+
return
|
|
583
|
+
from autoclicker.gui.preview_label import ndarray_to_qimage
|
|
584
|
+
from PySide6.QtGui import QPainter, QPen, QColor
|
|
585
|
+
img = ndarray_to_qimage(self._last_capture)
|
|
586
|
+
painter = QPainter(img)
|
|
587
|
+
painter.setPen(QPen(QColor(0, 255, 0), 2))
|
|
588
|
+
for r in results:
|
|
589
|
+
painter.drawRect(r.x, r.y, r.w, r.h)
|
|
590
|
+
painter.end()
|
|
591
|
+
self.preview.set_image_qimage(img)
|
|
592
|
+
self._append_log(f"OCR: 识别到 {len(results)} 处文字")
|
|
593
|
+
|
|
594
|
+
# ---- task package (.zip: task.json + images/) ----
|
|
595
|
+
def _iter_image_slots(self, task: Task):
|
|
596
|
+
"""Yield (get, set) callables for every image path field in the task."""
|
|
597
|
+
for s in task.steps:
|
|
598
|
+
if s.imagePath:
|
|
599
|
+
yield (lambda s=s: s.imagePath), (lambda v, s=s: setattr(s, "imagePath", v))
|
|
600
|
+
for i in range(len(s.imagePaths)):
|
|
601
|
+
yield (lambda s=s, i=i: s.imagePaths[i]), (lambda v, s=s, i=i: s.imagePaths.__setitem__(i, v))
|
|
602
|
+
if s.until.image:
|
|
603
|
+
yield (lambda s=s: s.until.image), (lambda v, s=s: setattr(s.until, "image", v))
|
|
604
|
+
|
|
605
|
+
def export_package(self):
|
|
606
|
+
import copy as _copy
|
|
607
|
+
path, _ = QFileDialog.getSaveFileName(self, "导出任务包", "", "任务包 (*.zip)")
|
|
608
|
+
if not path:
|
|
609
|
+
return
|
|
610
|
+
if not path.lower().endswith(".zip"):
|
|
611
|
+
path += ".zip"
|
|
612
|
+
task = _copy.deepcopy(self._current_task())
|
|
613
|
+
seen: dict[str, str] = {}
|
|
614
|
+
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
|
|
615
|
+
for get, setv in self._iter_image_slots(task):
|
|
616
|
+
src = get()
|
|
617
|
+
if not src or not os.path.exists(src):
|
|
618
|
+
continue
|
|
619
|
+
if src not in seen:
|
|
620
|
+
arc = f"images/{os.path.basename(src)}"
|
|
621
|
+
# avoid collisions
|
|
622
|
+
base, ext = os.path.splitext(arc)
|
|
623
|
+
n = 1
|
|
624
|
+
while arc in seen.values():
|
|
625
|
+
arc = f"{base}_{n}{ext}"
|
|
626
|
+
n += 1
|
|
627
|
+
z.write(src, arc)
|
|
628
|
+
seen[src] = arc
|
|
629
|
+
setv(seen[src])
|
|
630
|
+
z.writestr("task.json", __import__("json").dumps(task.to_json(), ensure_ascii=False, indent=4))
|
|
631
|
+
self._append_log(f"已导出任务包: {path}")
|
|
632
|
+
|
|
633
|
+
def import_package(self):
|
|
634
|
+
path, _ = QFileDialog.getOpenFileName(self, "导入任务包", "", "任务包 (*.zip)")
|
|
635
|
+
if not path:
|
|
636
|
+
return
|
|
637
|
+
out_dir = os.path.join(os.getcwd(), ".packages", os.path.splitext(os.path.basename(path))[0])
|
|
638
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
639
|
+
with zipfile.ZipFile(path, "r") as z:
|
|
640
|
+
z.extractall(out_dir)
|
|
641
|
+
task_json = os.path.join(out_dir, "task.json")
|
|
642
|
+
if not os.path.exists(task_json):
|
|
643
|
+
QMessageBox.warning(self, "导入失败", "任务包内缺少 task.json")
|
|
644
|
+
return
|
|
645
|
+
task = Task.load_from_file(task_json)
|
|
646
|
+
for get, setv in self._iter_image_slots(task):
|
|
647
|
+
rel = get()
|
|
648
|
+
if rel and not os.path.isabs(rel):
|
|
649
|
+
setv(os.path.join(out_dir, rel))
|
|
650
|
+
self._load_task_into_editors(task, "")
|
|
651
|
+
self._append_log(f"已导入任务包: {path} → {out_dir} ({len(task.steps)} 步)")
|