CCL_QMS检验
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

484 lines
18 KiB

  1. # -*- coding: utf-8 -*-
  2. import json
  3. import os
  4. import queue
  5. import sys
  6. import threading
  7. import time
  8. from datetime import datetime
  9. from pathlib import Path
  10. from tkinter import Tk, Label, Entry, Button, StringVar, END, DISABLED, NORMAL, filedialog, messagebox, simpledialog
  11. from tkinter.scrolledtext import ScrolledText
  12. import requests
  13. def get_runtime_dir():
  14. if getattr(sys, "frozen", False):
  15. return Path(sys.executable).resolve().parent
  16. return Path(__file__).resolve().parent
  17. def _ensure_windows_run_registry(entry_name, exe_path):
  18. import winreg
  19. run_key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
  20. target_value = "\"%s\"" % exe_path
  21. try:
  22. with winreg.OpenKey(
  23. winreg.HKEY_CURRENT_USER,
  24. run_key_path,
  25. 0,
  26. winreg.KEY_READ | winreg.KEY_SET_VALUE
  27. ) as key:
  28. try:
  29. old_value, _ = winreg.QueryValueEx(key, entry_name)
  30. except FileNotFoundError:
  31. old_value = None
  32. if old_value and str(old_value).strip().strip("\"").lower() == exe_path.lower():
  33. return False, "系统自启动项已存在,无需重复写入。"
  34. winreg.SetValueEx(key, entry_name, 0, winreg.REG_SZ, target_value)
  35. if old_value:
  36. return True, "检测到自启动路径变化,已自动更新。"
  37. return True, "首次启动已写入系统自启动项,下次开机会自动运行。"
  38. except Exception as e:
  39. return False, "写入系统自启动项失败: %s" % e
  40. def _cleanup_legacy_startup_items(legacy_entry_name, app_data):
  41. if app_data:
  42. startup_dir = Path(app_data) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
  43. legacy_script = startup_dir / (legacy_entry_name + ".vbs")
  44. try:
  45. if legacy_script.exists():
  46. legacy_script.unlink()
  47. except Exception:
  48. pass
  49. try:
  50. import winreg
  51. run_key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
  52. with winreg.OpenKey(
  53. winreg.HKEY_CURRENT_USER,
  54. run_key_path,
  55. 0,
  56. winreg.KEY_SET_VALUE
  57. ) as key:
  58. try:
  59. winreg.DeleteValue(key, legacy_entry_name)
  60. except FileNotFoundError:
  61. pass
  62. except Exception:
  63. pass
  64. def ensure_windows_startup():
  65. if os.name != "nt":
  66. return False, "当前系统非Windows,跳过自启动注册。"
  67. if not getattr(sys, "frozen", False):
  68. return False, "当前为源码运行,跳过自启动注册。"
  69. exe_path = str(Path(sys.executable).resolve())
  70. entry_name = "QMSFileCollector"
  71. app_data = os.getenv("APPDATA", "").strip()
  72. _cleanup_legacy_startup_items("CKPClientFileCollector", app_data)
  73. if app_data:
  74. startup_dir = Path(app_data) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
  75. startup_script = startup_dir / (entry_name + ".vbs")
  76. script_content = (
  77. "Set WshShell = CreateObject(\"WScript.Shell\")\n"
  78. "WshShell.Run Chr(34) & \"%s\" & Chr(34), 0\n"
  79. "Set WshShell = Nothing\n"
  80. ) % exe_path.replace("\"", "\"\"")
  81. try:
  82. startup_dir.mkdir(parents=True, exist_ok=True)
  83. old_content = ""
  84. if startup_script.exists():
  85. old_content = startup_script.read_text(encoding="utf-8")
  86. if old_content == script_content:
  87. return False, "启动文件夹自启动项已存在,无需重复写入。"
  88. startup_script.write_text(script_content, encoding="utf-8")
  89. if old_content:
  90. return True, "检测到启动文件夹自启动路径变化,已自动更新。"
  91. return True, "首次启动已写入启动文件夹自启动项,下次开机会自动运行。"
  92. except Exception as startup_error:
  93. try:
  94. changed, reg_msg = _ensure_windows_run_registry(entry_name, exe_path)
  95. return changed, "写入启动文件夹失败,已回退注册表方式: %s" % reg_msg
  96. except Exception as reg_error:
  97. return False, "写入启动文件夹和注册表均失败: %s; %s" % (startup_error, reg_error)
  98. try:
  99. return _ensure_windows_run_registry(entry_name, exe_path)
  100. except Exception as e:
  101. return False, "未获取到APPDATA且注册表写入失败: %s" % e
  102. class CollectorApp:
  103. def __init__(self, root):
  104. self.root = root
  105. self.root.title("文件采集客户端")
  106. self.root.geometry("1180x720")
  107. self.root.resizable(True, True)
  108. self.config_path = get_runtime_dir() / "collector_config.json"
  109. self.log_queue = queue.Queue()
  110. self.running = False
  111. self.worker_thread = None
  112. self.active_rows = []
  113. self.upload_state = {}
  114. self.notice_ts = {}
  115. self.base_url_var = StringVar()
  116. self.poll_seconds_var = StringVar(value="10")
  117. self.row_vars = []
  118. for _ in range(3):
  119. self.row_vars.append({
  120. "site": StringVar(),
  121. "bu_no": StringVar(),
  122. "equipment_no": StringVar(),
  123. "source_path": StringVar(),
  124. })
  125. self._build_ui()
  126. self._init_windows_startup()
  127. self._load_config()
  128. self.root.after(200, self._flush_logs)
  129. def _init_windows_startup(self):
  130. changed, message = ensure_windows_startup()
  131. if changed:
  132. self.log(message)
  133. return
  134. if getattr(sys, "frozen", False) and ("失败" in message):
  135. self.log(message)
  136. def _build_ui(self):
  137. Label(self.root, text="系统地址").place(x=20, y=20)
  138. Entry(self.root, textvariable=self.base_url_var, width=92).place(x=85, y=20)
  139. Button(self.root, text="弹框设置地址", command=self.popup_set_base_url).place(x=860, y=16)
  140. Button(self.root, text="保存配置", command=self.save_config).place(x=970, y=16)
  141. Label(self.root, text="轮询秒").place(x=20, y=58)
  142. Entry(self.root, textvariable=self.poll_seconds_var, width=10).place(x=85, y=58)
  143. Button(self.root, text="开始同步", command=self.start_collect).place(x=180, y=54)
  144. Button(self.root, text="停止同步", command=self.stop_collect).place(x=265, y=54)
  145. Label(self.root, text="配置说明:最多3行,每行需填写 site + buNo + equipmentNo + 本地目录").place(x=360, y=58)
  146. Label(self.root, text="").place(x=20, y=100)
  147. Label(self.root, text="Site").place(x=80, y=100)
  148. Label(self.root, text="BuNo").place(x=230, y=100)
  149. Label(self.root, text="EquipmentNo").place(x=380, y=100)
  150. Label(self.root, text="本地目录").place(x=560, y=100)
  151. for idx, row in enumerate(self.row_vars):
  152. y = 130 + idx * 40
  153. Label(self.root, text=str(idx + 1)).place(x=26, y=y)
  154. Entry(self.root, textvariable=row["site"], width=16).place(x=80, y=y)
  155. Entry(self.root, textvariable=row["bu_no"], width=16).place(x=230, y=y)
  156. Entry(self.root, textvariable=row["equipment_no"], width=18).place(x=380, y=y)
  157. Entry(self.root, textvariable=row["source_path"], width=56).place(x=560, y=y)
  158. Button(self.root, text="选择目录", command=lambda x=idx: self.choose_source_dir(x)).place(x=1010, y=y - 4)
  159. self.log_text = ScrolledText(self.root, width=165, height=23)
  160. self.log_text.place(x=20, y=280)
  161. self.log_text.configure(state=DISABLED)
  162. def popup_set_base_url(self):
  163. new_url = simpledialog.askstring(
  164. "设置目标地址",
  165. "请输入xujie-sys地址,例如:http://172.26.58.88:8080",
  166. initialvalue=self.base_url_var.get().strip()
  167. )
  168. if new_url is not None:
  169. self.base_url_var.set(new_url.strip())
  170. self.log("已设置目标地址: %s" % new_url.strip())
  171. def choose_source_dir(self, row_index):
  172. path = filedialog.askdirectory(title="选择第%d行本地目录" % (row_index + 1))
  173. if path:
  174. self.row_vars[row_index]["source_path"].set(path)
  175. def _load_config(self):
  176. if not self.config_path.exists():
  177. self.log("未找到本地配置,请先录入并保存。")
  178. return
  179. try:
  180. config = json.loads(self.config_path.read_text(encoding="utf-8"))
  181. self.base_url_var.set(config.get("base_url", ""))
  182. self.poll_seconds_var.set(str(config.get("poll_seconds", "10")))
  183. rows = config.get("rows")
  184. # 兼容旧版本单行配置
  185. if not isinstance(rows, list):
  186. rows = [{
  187. "site": config.get("site", ""),
  188. "bu_no": config.get("bu_no", ""),
  189. "equipment_no": config.get("equipment_no", ""),
  190. "source_path": config.get("source_path", ""),
  191. }]
  192. for idx in range(min(3, len(rows))):
  193. item = rows[idx] if isinstance(rows[idx], dict) else {}
  194. self.row_vars[idx]["site"].set(str(item.get("site", "")).strip())
  195. self.row_vars[idx]["bu_no"].set(str(item.get("bu_no", "")).strip())
  196. self.row_vars[idx]["equipment_no"].set(str(item.get("equipment_no", "")).strip())
  197. self.row_vars[idx]["source_path"].set(str(item.get("source_path", "")).strip())
  198. self.log("配置加载完成。")
  199. if self._can_auto_start():
  200. self.start_collect(auto_mode=True)
  201. except Exception as e:
  202. self.log("读取配置失败: %s" % e)
  203. def save_config(self):
  204. try:
  205. rows = self._get_valid_rows(require_complete=True, raise_on_empty=False)
  206. except ValueError as e:
  207. messagebox.showwarning("提示", str(e))
  208. return
  209. data = {
  210. "base_url": self.base_url_var.get().strip(),
  211. "poll_seconds": self.poll_seconds_var.get().strip(),
  212. "rows": rows,
  213. }
  214. try:
  215. self.config_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  216. self.log("配置已保存: %s" % self.config_path)
  217. except Exception as e:
  218. messagebox.showerror("错误", "保存配置失败: %s" % e)
  219. return
  220. if self.running:
  221. self.active_rows = rows
  222. self.log("采集配置已更新,下一轮轮询自动生效。")
  223. elif self._can_auto_start():
  224. self.start_collect(auto_mode=True)
  225. def _can_auto_start(self):
  226. if not self._base_url():
  227. return False
  228. if not self._is_poll_seconds_valid():
  229. return False
  230. try:
  231. rows = self._get_valid_rows(require_complete=True, raise_on_empty=True)
  232. return len(rows) > 0
  233. except Exception:
  234. return False
  235. def _is_poll_seconds_valid(self):
  236. try:
  237. return int(self.poll_seconds_var.get().strip()) > 0
  238. except Exception:
  239. return False
  240. def _base_url(self):
  241. return self.base_url_var.get().strip().rstrip("/")
  242. def _get_valid_rows(self, require_complete, raise_on_empty):
  243. rows = []
  244. for idx, row in enumerate(self.row_vars):
  245. site = row["site"].get().strip()
  246. bu_no = row["bu_no"].get().strip()
  247. equipment_no = row["equipment_no"].get().strip()
  248. source_path = row["source_path"].get().strip()
  249. filled_count = 0
  250. for value in (site, bu_no, equipment_no, source_path):
  251. if value:
  252. filled_count += 1
  253. if filled_count == 0:
  254. continue
  255. if require_complete and filled_count < 4:
  256. raise ValueError("%d行配置未填写完整,请补全site、buNo、equipmentNo、本地目录。" % (idx + 1))
  257. if filled_count == 4:
  258. rows.append({
  259. "row_index": idx + 1,
  260. "site": site,
  261. "bu_no": bu_no,
  262. "equipment_no": equipment_no,
  263. "source_path": source_path,
  264. })
  265. if raise_on_empty and len(rows) == 0:
  266. raise ValueError("请至少填写一行完整采集配置。")
  267. return rows
  268. def start_collect(self, auto_mode=False):
  269. if self.running:
  270. if not auto_mode:
  271. self.log("采集任务已在运行中。")
  272. return
  273. if not self._base_url():
  274. if auto_mode:
  275. self.log("自动启动失败:系统地址为空。")
  276. else:
  277. messagebox.showwarning("提示", "请先设置系统地址")
  278. return
  279. try:
  280. poll_seconds = int(self.poll_seconds_var.get().strip() or "10")
  281. if poll_seconds <= 0:
  282. raise ValueError("轮询秒必须大于0")
  283. except Exception:
  284. if auto_mode:
  285. self.log("自动启动失败:轮询秒无效。")
  286. else:
  287. messagebox.showwarning("提示", "轮询秒必须是大于0的整数")
  288. return
  289. try:
  290. rows = self._get_valid_rows(require_complete=True, raise_on_empty=True)
  291. except ValueError as e:
  292. if auto_mode:
  293. self.log("自动启动失败:%s" % e)
  294. else:
  295. messagebox.showwarning("提示", str(e))
  296. return
  297. self.active_rows = rows
  298. self.running = True
  299. self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
  300. self.worker_thread.start()
  301. if auto_mode:
  302. self.log("配置有效,已自动启动轮询同步。")
  303. else:
  304. self.log("采集任务已启动。")
  305. def stop_collect(self):
  306. if not self.running:
  307. self.log("采集任务未运行。")
  308. return
  309. self.running = False
  310. self.log("正在停止采集任务...")
  311. def _worker_loop(self):
  312. while self.running:
  313. rows = list(self.active_rows)
  314. for row in rows:
  315. if not self.running:
  316. break
  317. self._sync_one_row(row)
  318. try:
  319. poll_seconds = int(self.poll_seconds_var.get().strip() or "10")
  320. except Exception:
  321. poll_seconds = 10
  322. for _ in range(max(1, poll_seconds)):
  323. if not self.running:
  324. break
  325. time.sleep(1)
  326. self.log("采集任务已停止。")
  327. def _sync_one_row(self, row):
  328. row_index = row["row_index"]
  329. source_path = row["source_path"]
  330. path_key = "row%d_missing" % row_index
  331. empty_key = "row%d_empty" % row_index
  332. if not os.path.isdir(source_path):
  333. self._log_with_interval(path_key, "%d行目录不存在: %s" % (row_index, source_path), 60)
  334. return
  335. files = []
  336. for name in os.listdir(source_path):
  337. file_path = Path(source_path) / name
  338. if file_path.is_file():
  339. files.append(file_path)
  340. if not files:
  341. self._log_with_interval(empty_key, "%d行目录为空,等待新文件..." % row_index, 60)
  342. return
  343. files.sort(key=lambda p: p.stat().st_mtime)
  344. for file_path in files:
  345. if not self.running:
  346. return
  347. signature = self._file_signature(file_path)
  348. if signature is None:
  349. continue
  350. state_key = "%d|%s" % (row_index, str(file_path).lower())
  351. if self.upload_state.get(state_key) == signature:
  352. continue
  353. if self._upload_file(row, file_path):
  354. self.upload_state[state_key] = signature
  355. def _file_signature(self, file_path):
  356. try:
  357. stat = file_path.stat()
  358. return stat.st_mtime_ns, stat.st_size
  359. except Exception as e:
  360. self.log("读取文件状态失败: %s, 原因: %s" % (file_path, e))
  361. return None
  362. def _upload_file(self, row, file_path):
  363. url = self._base_url() + "/collector/client/upload"
  364. data = {
  365. "site": row["site"],
  366. "buNo": row["bu_no"],
  367. "equipmentNo": row["equipment_no"],
  368. }
  369. try:
  370. with open(file_path, "rb") as fp:
  371. files = {"file": (file_path.name, fp, "application/octet-stream")}
  372. resp = requests.post(url, data=data, files=files, timeout=180)
  373. resp.raise_for_status()
  374. result = resp.json()
  375. if result.get("code") == 0:
  376. response_data = result.get("data") or {}
  377. server_path = response_data.get("savedFullPath", "")
  378. self.log("%d行上传成功: %s -> %s" % (row["row_index"], file_path.name, server_path))
  379. return True
  380. self.log("%d行上传失败: %s, 原因: %s" % (
  381. row["row_index"], file_path.name, result.get("msg")))
  382. return False
  383. except Exception as e:
  384. self.log("%d行上传异常: %s, 原因: %s" % (row["row_index"], file_path.name, e))
  385. return False
  386. def _log_with_interval(self, key, message, seconds):
  387. now = time.time()
  388. last = self.notice_ts.get(key, 0)
  389. if now - last >= seconds:
  390. self.notice_ts[key] = now
  391. self.log(message)
  392. def log(self, message):
  393. self.log_queue.put("[%s] %s" % (datetime.now().strftime("%H:%M:%S"), message))
  394. def _flush_logs(self):
  395. while not self.log_queue.empty():
  396. line = self.log_queue.get()
  397. self.log_text.configure(state=NORMAL)
  398. self.log_text.insert(END, line + "\n")
  399. self.log_text.see(END)
  400. self.log_text.configure(state=DISABLED)
  401. self.root.after(200, self._flush_logs)
  402. if __name__ == "__main__":
  403. app_root = Tk()
  404. app = CollectorApp(app_root)
  405. app_root.mainloop()