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.
@@ -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)} 步)")