From 71621dc8a01d15545c8ea39bf1d80abd78b27aad Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Thu, 5 Feb 2026 13:33:23 +0800 Subject: [PATCH] feat: add Tkinter GUI application for ERP automation tools Implement a comprehensive GUI application with the following features: - Data Extraction tab: Extract material plan data from ERP system - Material Validation tab: Validate material status and match deletions - Data Query tab: Query database for production order information - Settings tab: Manage ERP, database, browser, and path configurations Key components: - MainWindow: Tabbed interface with status bar - ConfigManager: JSON-based configuration management - LogText: Custom read-only text widget with colored logging - FileSelector: Reusable file/directory selection component - ProgressDialog: Modal progress dialog for long operations Technical details: - Thread-safe UI updates using root.after() - Stdout capture for legacy script integration - Event-based readonly mode allowing copy/select operations - Custom widget composition to avoid Tkinter ScrolledText issues Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 6 +- config/user_settings.py | 40 ++++ gui/__init__.py | 7 + gui/config_manager.py | 141 +++++++++++++ gui/data_extraction_tab.py | 258 +++++++++++++++++++++++ gui/data_query_tab.py | 306 +++++++++++++++++++++++++++ gui/main_window.py | 125 +++++++++++ gui/material_validation_tab.py | 374 +++++++++++++++++++++++++++++++++ gui/settings_tab.py | 279 ++++++++++++++++++++++++ gui/widgets/__init__.py | 10 + gui/widgets/file_selector.py | 96 +++++++++ gui/widgets/log_text.py | 180 ++++++++++++++++ gui/widgets/progress_dialog.py | 120 +++++++++++ main_ui.py | 36 ++++ 14 files changed, 1977 insertions(+), 1 deletion(-) create mode 100644 config/user_settings.py create mode 100644 gui/__init__.py create mode 100644 gui/config_manager.py create mode 100644 gui/data_extraction_tab.py create mode 100644 gui/data_query_tab.py create mode 100644 gui/main_window.py create mode 100644 gui/material_validation_tab.py create mode 100644 gui/settings_tab.py create mode 100644 gui/widgets/__init__.py create mode 100644 gui/widgets/file_selector.py create mode 100644 gui/widgets/log_text.py create mode 100644 gui/widgets/progress_dialog.py create mode 100644 main_ui.py diff --git a/.gitignore b/.gitignore index e8f320b..7310a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,8 @@ orderID.txt ProductionID.txt # 测试脚本 -tests/ \ No newline at end of file +tests/ + +# 用户配置文件(包含敏感信息) +config/user_settings.json +nul \ No newline at end of file diff --git a/config/user_settings.py b/config/user_settings.py new file mode 100644 index 0000000..1f973ed --- /dev/null +++ b/config/user_settings.py @@ -0,0 +1,40 @@ +""" +用户配置模板 + +此文件包含用户配置的默认值和结构说明。 +实际配置保存在 config/user_settings.json +""" + +DEFAULT_SETTINGS = { + "erp": { + "url": "https://68.11.34.30:8082/", + "username": "BLDpengqiangqiang", + "password": "Cqbld123456.", + "headless": True, + "ignore_https_errors": True, + "auto_close_browser": True + }, + "database": { + "server": "192.168.110.114", + "database": "CompanyDB", + "username": "peng", + "password": "Cqbld123456." + }, + "browser": { + "headless": True, + "ignore_https_errors": True, + "auto_close": True + }, + "paths": { + "data_dir": "D:/python/playwrite/data/", + "production_id_file": "ProductionID.txt", + "default_output": "离散备料计划维护_合并.xlsx", + "validation_output": "物料状态校验结果.xlsx" + }, + "extraction": { + "batch_size": 100, + "verbose": True, + "auto_convert": True, + "merge_batches": True + } +} diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..86a82f7 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,7 @@ +""" +ERP 自动化工具 - GUI 模块 + +提供图形用户界面用于执行 ERP 系统自动化任务。 +""" + +__version__ = "1.0.0" diff --git a/gui/config_manager.py b/gui/config_manager.py new file mode 100644 index 0000000..0a4e862 --- /dev/null +++ b/gui/config_manager.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +配置管理器 + +负责加载、保存和管理用户配置。 +""" + +import json +import os +from typing import Any, Dict +from config.user_settings import DEFAULT_SETTINGS + + +class ConfigManager: + """配置管理器""" + + def __init__(self, config_file: str = "config/user_settings.json"): + """ + 初始化配置管理器 + + Args: + config_file: 配置文件路径 + """ + self.config_file = config_file + self.settings = self.load() + + def load(self) -> Dict[str, Any]: + """ + 加载配置文件 + + Returns: + 配置字典,如果文件不存在则返回默认配置 + """ + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + loaded_settings = json.load(f) + # 合并默认配置,确保所有必需的键都存在 + return self._merge_settings(DEFAULT_SETTINGS, loaded_settings) + except (json.JSONDecodeError, IOError) as e: + print(f"加载配置文件失败: {e}") + return DEFAULT_SETTINGS.copy() + else: + # 首次运行,创建默认配置文件 + self.save(DEFAULT_SETTINGS.copy()) + return DEFAULT_SETTINGS.copy() + + def save(self, settings: Dict[str, Any] = None) -> bool: + """ + 保存配置到文件 + + Args: + settings: 要保存的配置字典,如果为 None 则保存当前配置 + + Returns: + 保存是否成功 + """ + if settings is not None: + self.settings = settings + + try: + # 确保配置目录存在 + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) + + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(self.settings, f, ensure_ascii=False, indent=2) + return True + except IOError as e: + print(f"保存配置文件失败: {e}") + return False + + def get(self, key: str, default=None) -> Any: + """ + 获取配置项 + + 支持点号分隔的路径,如 "erp.url" + + Args: + key: 配置键 + default: 默认值 + + Returns: + 配置值 + """ + keys = key.split('.') + value = self.settings + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def set(self, key: str, value: Any) -> None: + """ + 设置配置项 + + 支持点号分隔的路径,如 "erp.url" + + Args: + key: 配置键 + value: 配置值 + """ + keys = key.split('.') + settings = self.settings + + for k in keys[:-1]: + if k not in settings: + settings[k] = {} + settings = settings[k] + + settings[keys[-1]] = value + + def reset_to_defaults(self) -> None: + """重置为默认配置""" + self.settings = DEFAULT_SETTINGS.copy() + self.save() + + def _merge_settings(self, defaults: Dict, loaded: Dict) -> Dict: + """ + 合并默认配置和加载的配置 + + Args: + defaults: 默认配置 + loaded: 加载的配置 + + Returns: + 合并后的配置 + """ + result = defaults.copy() + + for key, value in loaded.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = self._merge_settings(result[key], value) + else: + result[key] = value + + return result diff --git a/gui/data_extraction_tab.py b/gui/data_extraction_tab.py new file mode 100644 index 0000000..9f0852d --- /dev/null +++ b/gui/data_extraction_tab.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +数据提取标签页 + +从 ERP 系统提取备料计划数据。 +""" + +import os +import sys +import threading +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from pathlib import Path +from io import StringIO +from contextlib import redirect_stdout +from gui.widgets import FileSelector, LogText +from gui.config_manager import ConfigManager + + +class DataExtractionTab(ttk.Frame): + """数据提取标签页""" + + def __init__(self, parent, config: ConfigManager): + """ + 初始化数据提取标签页 + + Args: + parent: 父容器 + config: 配置管理器 + """ + super().__init__(parent) + self.config = config + self.extracting = False + self.extractor = None + self.extraction_thread = None + + self.create_widgets() + + # 稍后显示就绪消息 + try: + self.log_text.info("数据提取标签页已就绪") + except: + pass # 如果窗口还未完全就绪,忽略错误 + + def create_widgets(self): + """创建界面组件""" + # 主容器 - 使用 PanedWindow 分割上下部分 + main_paned = ttk.PanedWindow(self, orient=tk.VERTICAL) + main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # 上部:控制面板 + control_frame = ttk.Frame(main_paned) + main_paned.add(control_frame, weight=0) + + # 下部:日志输出 + log_frame = ttk.LabelFrame(main_paned, text="日志输出", padding=5) + main_paned.add(log_frame, weight=1) + + self._create_control_panel(control_frame) + self._create_log_panel(log_frame) + + def _create_control_panel(self, parent): + """创建控制面板""" + # 输入文件选择 + input_group = ttk.LabelFrame(parent, text="输入文件", padding=10) + input_group.pack(fill=tk.X, pady=5) + + self.input_file_selector = FileSelector( + input_group, + label_text="ProductionID 文件:", + file_type="file", + file_types=[("文本文件", "*.txt"), ("所有文件", "*.*")], + initial_dir="D:/python/playwrite/" + ) + self.input_file_selector.pack(fill=tk.X) + + # 设置默认文件 + default_input = self.config.get('paths.production_id_file', 'ProductionID.txt') + if os.path.exists(default_input): + self.input_file_selector.set(default_input) + + # 输出文件选择 + output_group = ttk.LabelFrame(parent, text="输出文件", padding=10) + output_group.pack(fill=tk.X, pady=5) + + self.output_file_selector = FileSelector( + output_group, + label_text="保存为:", + file_type="file", + file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], + initial_dir=self.config.get('paths.data_dir', 'data/') + ) + self.output_file_selector.pack(fill=tk.X) + + # 设置默认输出文件 + default_output = os.path.join( + self.config.get('paths.data_dir', 'data/'), + self.config.get('paths.default_output', '离散备料计划维护_合并.xlsx') + ) + self.output_file_selector.set(default_output) + + # 选项 + options_group = ttk.LabelFrame(parent, text="提取选项", padding=10) + options_group.pack(fill=tk.X, pady=5) + + self.verbose_var = tk.BooleanVar(value=self.config.get('extraction.verbose', True)) + ttk.Checkbutton(options_group, text="详细日志", variable=self.verbose_var).grid(row=0, column=0, sticky="w", padx=5) + + self.headless_var = tk.BooleanVar(value=self.config.get('browser.headless', True)) + ttk.Checkbutton(options_group, text="无头模式 (不显示浏览器)", variable=self.headless_var).grid(row=0, column=1, sticky="w", padx=5) + + # 进度显示 + progress_group = ttk.LabelFrame(parent, text="进度", padding=10) + progress_group.pack(fill=tk.X, pady=5) + + self.progress_bar = ttk.Progressbar(progress_group, mode='determinate') + self.progress_bar.pack(fill=tk.X, pady=5) + + self.status_label = ttk.Label(progress_group, text="就绪", relief=tk.SUNKEN, anchor=tk.W) + self.status_label.pack(fill=tk.X) + + # 控制按钮 + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, pady=10) + + self.start_button = ttk.Button(button_frame, text="开始提取", command=self.start_extraction) + self.start_button.pack(side=tk.LEFT, padx=5) + + self.stop_button = ttk.Button(button_frame, text="停止", command=self.stop_extraction, state=tk.DISABLED) + self.stop_button.pack(side=tk.LEFT, padx=5) + + def _create_log_panel(self, parent): + """创建日志面板""" + self.log_text = LogText(parent, height=15, readonly=True) + self.log_text.pack(fill=tk.BOTH, expand=True) + + def start_extraction(self): + """开始数据提取""" + # 验证输入 + input_file = self.input_file_selector.get() + output_file = self.output_file_selector.get() + + if not input_file: + messagebox.showerror("错误", "请选择 ProductionID 输入文件") + return + + if not os.path.exists(input_file): + messagebox.showerror("错误", f"输入文件不存在:{input_file}") + return + + if not output_file: + messagebox.showerror("错误", "请指定输出文件路径") + return + + # 确保输出目录存在 + output_dir = os.path.dirname(output_file) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + # 更新 UI 状态 + self.extracting = True + self.start_button.config(state=tk.DISABLED) + self.stop_button.config(state=tk.NORMAL) + self.progress_bar['value'] = 0 + self.log_text.clear() + self.log_text.info("开始数据提取...") + + # 在后台线程中执行提取 + self.extraction_thread = threading.Thread( + target=self._extraction_worker, + args=(input_file, output_file), + daemon=True + ) + self.extraction_thread.start() + + def stop_extraction(self): + """停止数据提取""" + if self.extracting: + self.extracting = False + self.log_text.warning("正在停止提取...") + self.status_label.config(text="正在停止...") + + def _extraction_worker(self, input_file: str, output_file: str): + """提取工作线程""" + try: + # 导入提取器(延迟导入以避免启动时加载 Playwright) + from utils.离散备料计划维护数据提取 import DiscreteMaterialPlanExtractor + + # 创建提取器实例 + self.extractor = DiscreteMaterialPlanExtractor( + username=self.config.get('erp.username'), + password=self.config.get('erp.password'), + headless=self.headless_var.get(), + verbose=self.verbose_var.get() + ) + + # 捕获 stdout 输出 + captured_output = StringIO() + + # 重定向 stdout 并执行提取 + with redirect_stdout(captured_output): + result = self.extractor.extract( + production_id_file=input_file, + output_file=output_file + ) + + # 获取捕获的输出并显示到日志 + output_text = captured_output.getvalue() + if output_text: + for line in output_text.split('\n'): + if line.strip(): + self._update_log(line, "INFO") + + if result and self.extracting: + self._update_progress(100, "提取完成") + self._update_log(f"数据已保存到:{output_file}", "SUCCESS") + elif not self.extracting: + self._update_log("提取已取消", "WARNING") + else: + self._update_log("提取失败", "ERROR") + + except Exception as e: + self._update_log(f"提取过程中发生错误:{str(e)}", "ERROR") + finally: + # 更新 UI 状态 + self.after(0, self._extraction_complete) + + def _extraction_complete(self): + """提取完成后的 UI 更新""" + self.extracting = False + self.start_button.config(state=tk.NORMAL) + self.stop_button.config(state=tk.DISABLED) + self.extractor = None + + def _update_progress(self, value: int, message: str): + """线程安全的进度更新""" + def update(): + if self.extracting: + self.progress_bar['value'] = value + self.status_label.config(text=message) + + self.after(0, update) + + def _update_log(self, message: str, level: str = "INFO"): + """线程安全的日志更新""" + def update(): + if self.extracting or level in ["ERROR", "WARNING", "SUCCESS"]: + if level == "INFO": + self.log_text.info(message) + elif level == "SUCCESS": + self.log_text.success(message) + elif level == "WARNING": + self.log_text.warning(message) + elif level == "ERROR": + self.log_text.error(message) + + self.after(0, update) diff --git a/gui/data_query_tab.py b/gui/data_query_tab.py new file mode 100644 index 0000000..164689d --- /dev/null +++ b/gui/data_query_tab.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +数据查询标签页 + +查询生产订单号等信息。 +""" + +import os +import threading +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from gui.widgets import LogText +from gui.config_manager import ConfigManager +import pandas as pd + + +class DataQueryTab(ttk.Frame): + """数据查询标签页""" + + def __init__(self, parent, config: ConfigManager): + """ + 初始化数据查询标签页 + + Args: + parent: 父容器 + config: 配置管理器 + """ + super().__init__(parent) + self.config = config + self.query_results = None + self.querying = False + + self.create_widgets() + + # 稍后显示就绪消息 + try: + self.log_text.info("数据查询标签页已就绪") + except: + pass # 如果窗口还未完全就绪,忽略错误 + + def create_widgets(self): + """创建界面组件""" + # 主容器 + main_container = ttk.Frame(self) + main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # 上部:查询控制 + control_frame = ttk.LabelFrame(main_container, text="查询条件", padding=10) + control_frame.pack(fill=tk.X, pady=(0, 10)) + + # 中部:结果表格 + result_frame = ttk.LabelFrame(main_container, text="查询结果", padding=5) + result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # 下部:日志 + log_frame = ttk.LabelFrame(main_container, text="日志", padding=5) + log_frame.pack(fill=tk.BOTH, expand=True) + + self._create_query_control(control_frame) + self._create_result_table(result_frame) + self._create_log_panel(log_frame) + + def _create_query_control(self, parent): + """创建查询控制面板""" + # 查询说明 + info_label = ttk.Label( + parent, + text="输入总排号列表(每行一个),查询对应的生产订单号信息", + foreground="#666666" + ) + info_label.pack(anchor=tk.W, pady=(0, 5)) + + # 输入区域 + input_frame = ttk.Frame(parent) + input_frame.pack(fill=tk.BOTH, expand=True) + + # 左侧:文本输入框 + text_frame = ttk.Frame(input_frame) + text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) + + self.input_text = tk.Text(text_frame, height=10, wrap=tk.WORD) + self.input_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + text_scrollbar = ttk.Scrollbar(text_frame, orient=tk.VERTICAL, command=self.input_text.yview) + text_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.input_text.configure(yscrollcommand=text_scrollbar.set) + + # 右侧:示例和按钮 + right_frame = ttk.Frame(input_frame, width=200) + right_frame.pack(side=tk.RIGHT, fill=tk.Y) + + # 示例文本 + example_label = ttk.Label(right_frame, text="示例格式:", foreground="#666666") + example_label.pack(anchor=tk.W, pady=(0, 5)) + + example_text = tk.Text(right_frame, height=8, width=25, wrap=tk.WORD) + example_text.pack(fill=tk.X) + example_text.insert("1.0", "24000001\n24000002\n24000003") + example_text.config(state=tk.DISABLED) + + # 快捷按钮 + ttk.Separator(right_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10) + + ttk.Button(right_frame, text="加载 ProductionID.txt", command=self._load_production_id).pack(fill=tk.X, pady=2) + ttk.Button(right_frame, text="清空输入", command=self._clear_input).pack(fill=tk.X, pady=2) + + # 查询按钮 + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, pady=(10, 0)) + + self.query_button = ttk.Button(button_frame, text="执行查询", command=self.execute_query) + self.query_button.pack(side=tk.LEFT, padx=5) + + self.export_button = ttk.Button(button_frame, text="导出结果", command=self.export_results, state=tk.DISABLED) + self.export_button.pack(side=tk.LEFT, padx=5) + + self.progress = ttk.Progressbar(parent, mode='indeterminate') + self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + + def _create_result_table(self, parent): + """创建结果表格""" + # 创建 Treeview + columns = ("总排号", "生产订单号", "订单状态") + self.tree = ttk.Treeview(parent, columns=columns, show="headings", height=12) + + # 设置列标题和宽度 + self.tree.heading("总排号", text="总排号") + self.tree.heading("生产订单号", text="生产订单号") + self.tree.heading("订单状态", text="订单状态") + + self.tree.column("总排号", width=150) + self.tree.column("生产订单号", width=300) + self.tree.column("订单状态", width=150) + + # 添加滚动条 + scrollbar_y = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.tree.yview) + scrollbar_x = ttk.Scrollbar(parent, orient=tk.HORIZONTAL, command=self.tree.xview) + + self.tree.configure(yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set) + + # 布局 + self.tree.grid(row=0, column=0, sticky="nsew") + scrollbar_y.grid(row=0, column=1, sticky="ns") + scrollbar_x.grid(row=1, column=0, sticky="ew") + + parent.rowconfigure(0, weight=1) + parent.columnconfigure(0, weight=1) + + def _create_log_panel(self, parent): + """创建日志面板""" + self.log_text = LogText(parent, height=6, readonly=True) + self.log_text.pack(fill=tk.BOTH, expand=True) + + def _load_production_id(self): + """加载 ProductionID.txt 文件""" + default_path = self.config.get('paths.production_id_file', 'ProductionID.txt') + + # 检查默认路径 + if os.path.exists(default_path): + file_path = default_path + else: + # 打开文件选择对话框 + from tkinter import filedialog + file_path = filedialog.askopenfilename( + title="选择 ProductionID 文件", + filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")] + ) + + if not file_path: + return + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + self.input_text.delete("1.0", tk.END) + self.input_text.insert("1.0", content) + self.log_text.info(f"已加载文件:{file_path}") + + except Exception as e: + messagebox.showerror("错误", f"加载文件失败:{str(e)}") + self.log_text.error(f"加载文件失败:{str(e)}") + + def _clear_input(self): + """清空输入""" + self.input_text.delete("1.0", tk.END) + + def execute_query(self): + """执行查询""" + # 获取输入的总排号列表 + input_text = self.input_text.get("1.0", tk.END).strip() + + if not input_text: + messagebox.showwarning("警告", "请输入总排号列表") + return + + # 解析总排号 + production_ids = [line.strip() for line in input_text.split('\n') if line.strip()] + + if not production_ids: + messagebox.showwarning("警告", "没有有效的总排号") + return + + self.log_text.info(f"准备查询 {len(production_ids)} 个总排号...") + + # 更新 UI 状态 + self.querying = True + self.query_button.config(state=tk.DISABLED) + self.progress.start(10) + + # 清空结果表格 + for item in self.tree.get_children(): + self.tree.delete(item) + + # 在后台线程中执行查询 + query_thread = threading.Thread( + target=self._query_worker, + args=(production_ids,), + daemon=True + ) + query_thread.start() + + def _query_worker(self, production_ids: list): + """查询工作线程""" + try: + # 导入查询函数 + from db.production_order_query import query_production_order_numbers + + self._update_log("正在连接数据库...", "INFO") + + # 执行查询 + results = query_production_order_numbers(production_ids) + + if results: + self._update_log(f"查询完成,共 {len(results)} 条结果", "SUCCESS") + self._display_results(results) + else: + self._update_log("查询完成,但没有找到匹配的结果", "WARNING") + + except Exception as e: + self._update_log(f"查询过程中发生错误:{str(e)}", "ERROR") + messagebox.showerror("错误", f"查询失败:{str(e)}") + finally: + # 更新 UI 状态 + self.after(0, self._query_complete) + + def _query_complete(self): + """查询完成后的 UI 更新""" + self.querying = False + self.query_button.config(state=tk.NORMAL) + self.progress.stop() + + def _display_results(self, results: list): + """在主线程中显示结果""" + def update(): + for 总排号, 生产订单号 in results: + self.tree.insert("", tk.END, values=(总排号, 生产订单号, "")) + + if len(results) > 0: + self.export_button.config(state=tk.NORMAL) + self.query_results = results + + self.after(0, update) + + def _update_log(self, message: str, level: str = "INFO"): + """线程安全的日志更新""" + def update(): + if level == "INFO": + self.log_text.info(message) + elif level == "SUCCESS": + self.log_text.success(message) + elif level == "WARNING": + self.log_text.warning(message) + elif level == "ERROR": + self.log_text.error(message) + + self.after(0, update) + + def export_results(self): + """导出结果到 Excel""" + if not self.query_results: + messagebox.showwarning("警告", "没有结果可导出") + return + + output_file = filedialog.asksaveasfilename( + title="导出查询结果", + defaultextension=".xlsx", + filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], + initialfile="生产订单号查询结果.xlsx" + ) + + if not output_file: + return + + try: + # 创建 DataFrame 并保存 + df = pd.DataFrame(self.query_results, columns=["总排号", "生产订单号"]) + df.to_excel(output_file, index=False) + + messagebox.showinfo("成功", f"结果已导出到:{output_file}") + self.log_text.success(f"结果已导出到:{output_file}") + + except Exception as e: + messagebox.showerror("错误", f"导出失败:{str(e)}") + self.log_text.error(f"导出失败:{str(e)}") diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..fc20870 --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +主窗口 + +ERP 自动化工具的主窗口,包含多个功能标签页。 +""" + +import tkinter as tk +from tkinter import ttk +from gui.config_manager import ConfigManager +from gui.data_extraction_tab import DataExtractionTab +from gui.material_validation_tab import MaterialValidationTab +from gui.data_query_tab import DataQueryTab +from gui.settings_tab import SettingsTab + + +class MainWindow: + """主窗口类""" + + def __init__(self, root: tk.Tk): + """ + 初始化主窗口 + + Args: + root: Tk 根窗口 + """ + self.root = root + self.config = ConfigManager() + + # 设置窗口属性 + self.root.title("ERP 自动化工具 v1.0") + self.root.geometry("1000x700") + + # 设置最小窗口大小 + self.root.minsize(800, 600) + + # 创建菜单栏 + self.create_menu() + + # 创建主界面 + self.create_widgets() + + # 创建状态栏 + self.create_status_bar() + + # 居中窗口 + self._center_window() + + def create_menu(self): + """创建菜单栏""" + menubar = tk.Menu(self.root) + self.root.config(menu=menubar) + + # 文件菜单 + file_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="文件", menu=file_menu) + file_menu.add_command(label="退出", command=self.root.quit) + + # 帮助菜单 + help_menu = tk.Menu(menubar, tearoff=0) + menubar.add_cascade(label="帮助", menu=help_menu) + help_menu.add_command(label="关于", command=self.show_about) + + def create_widgets(self): + """创建主界面组件""" + # 创建 Notebook(标签页容器) + self.notebook = ttk.Notebook(self.root) + self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # 数据提取标签页 + self.extraction_tab = DataExtractionTab(self.notebook, self.config) + self.notebook.add(self.extraction_tab, text="数据提取") + + # 物料校验标签页 + self.validation_tab = MaterialValidationTab(self.notebook, self.config) + self.notebook.add(self.validation_tab, text="物料校验") + + # 数据查询标签页 + self.query_tab = DataQueryTab(self.notebook, self.config) + self.notebook.add(self.query_tab, text="数据查询") + + # 设置标签页 + self.settings_tab = SettingsTab(self.notebook, self.config) + self.notebook.add(self.settings_tab, text="设置") + + def create_status_bar(self): + """创建状态栏""" + self.status_bar = ttk.Frame(self.root, relief=tk.SUNKEN) + self.status_bar.pack(side=tk.BOTTOM, fill=tk.X) + + # 状态文本 + self.status_text = tk.StringVar() + self.status_text.set("就绪") + status_label = ttk.Label(self.status_bar, textvariable=self.status_text, anchor=tk.W) + status_label.pack(side=tk.LEFT, padx=5) + + # 配置状态指示 + self.config_status = tk.StringVar() + self.config_status.set("配置已加载") + config_label = ttk.Label(self.status_bar, textvariable=self.config_status, anchor=tk.E) + config_label.pack(side=tk.RIGHT, padx=5) + + def _center_window(self): + """将窗口居中显示""" + self.root.update_idletasks() + width = self.root.winfo_width() + height = self.root.winfo_height() + x = (self.root.winfo_screenwidth() // 2) - (width // 2) + y = (self.root.winfo_screenheight() // 2) - (height // 2) + self.root.geometry(f"{width}x{height}+{x}+{y}") + + def show_about(self): + """显示关于对话框""" + from tkinter import messagebox + messagebox.showinfo( + "关于 ERP 自动化工具", + "ERP 自动化工具 v1.0\n\n" + "功能:\n" + "• 数据提取 - 从 ERP 系统提取备料计划数据\n" + "• 物料校验 - 校验物料状态并匹配待删除物料\n" + "• 数据查询 - 查询生产订单号等信息\n" + "• 设置管理 - 管理系统配置\n\n" + "基于 Playwright 和 Python 开发" + ) diff --git a/gui/material_validation_tab.py b/gui/material_validation_tab.py new file mode 100644 index 0000000..0bab25f --- /dev/null +++ b/gui/material_validation_tab.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +物料校验标签页 + +校验物料状态并匹配待删除物料。 +""" + +import os +import threading +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from io import StringIO +from contextlib import redirect_stdout +from gui.widgets import FileSelector, LogText +from gui.config_manager import ConfigManager +import pandas as pd + + +class MaterialValidationTab(ttk.Frame): + """物料校验标签页""" + + def __init__(self, parent, config: ConfigManager): + """ + 初始化物料校验标签页 + + Args: + parent: 父容器 + config: 配置管理器 + """ + super().__init__(parent) + self.config = config + self.validating = False + self.validation_results = None + + self.create_widgets() + + # 稍后显示就绪消息 + try: + self.log_text.info("物料校验标签页已就绪") + except: + pass # 如果窗口还未完全就绪,忽略错误 + + def create_widgets(self): + """创建界面组件""" + # 主容器 + main_container = ttk.Frame(self) + main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) + + # 上部:控制面板 + control_frame = ttk.Frame(main_container) + control_frame.pack(fill=tk.X, pady=(0, 10)) + + # 中部:结果表格 + result_frame = ttk.LabelFrame(main_container, text="校验结果", padding=5) + result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + # 下部:日志输出 + log_frame = ttk.LabelFrame(main_container, text="日志", padding=5) + log_frame.pack(fill=tk.BOTH, expand=True) + + self._create_control_panel(control_frame) + self._create_result_table(result_frame) + self._create_log_panel(log_frame) + + def _create_control_panel(self, parent): + """创建控制面板""" + # 数据来源选择 + source_group = ttk.LabelFrame(parent, text="数据来源", padding=10) + source_group.pack(fill=tk.X, pady=5) + + self.source_mode = tk.StringVar(value="existing") + ttk.Radiobutton( + source_group, + text="使用现有 Excel 文件", + variable=self.source_mode, + value="existing", + command=self._on_source_mode_change + ).grid(row=0, column=0, sticky="w", padx=5) + + ttk.Radiobutton( + source_group, + text="完整工作流 (提取 + 校验)", + variable=self.source_mode, + value="full", + command=self._on_source_mode_change + ).grid(row=0, column=1, sticky="w", padx=5) + + # 文件选择 + file_group = ttk.LabelFrame(parent, text="文件选择", padding=10) + file_group.pack(fill=tk.X, pady=5) + + # 现有 Excel 文件 + self.existing_excel_frame = ttk.Frame(file_group) + self.existing_excel_frame.grid(row=0, column=0, columnspan=2, sticky="ew") + + self.existing_excel_selector = FileSelector( + self.existing_excel_frame, + label_text="现有 Excel 文件:", + file_type="file", + file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], + initial_dir=self.config.get('paths.data_dir', 'data/') + ) + self.existing_excel_selector.pack(fill=tk.X) + + # ProductionID 文件 (完整工作流模式) + self.production_id_frame = ttk.Frame(file_group) + # 初始隐藏 + + self.production_id_selector = FileSelector( + self.production_id_frame, + label_text="ProductionID 文件:", + file_type="file", + file_types=[("文本文件", "*.txt"), ("所有文件", "*.*")], + initial_dir="D:/python/playwrite/" + ) + self.production_id_selector.pack(fill=tk.X) + + # 输出文件 + output_frame = ttk.Frame(file_group) + output_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + + self.output_file_selector = FileSelector( + output_frame, + label_text="输出文件:", + file_type="file", + file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")], + initial_dir=self.config.get('paths.data_dir', 'data/') + ) + self.output_file_selector.pack(fill=tk.X) + + # 设置默认输出 + default_output = os.path.join( + self.config.get('paths.data_dir', 'data/'), + self.config.get('paths.validation_output', '物料状态校验结果.xlsx') + ) + self.output_file_selector.set(default_output) + + # 控制按钮 + button_frame = ttk.Frame(parent) + button_frame.pack(fill=tk.X, pady=10) + + self.start_button = ttk.Button(button_frame, text="开始校验", command=self.start_validation) + self.start_button.pack(side=tk.LEFT, padx=5) + + self.export_button = ttk.Button(button_frame, text="导出结果", command=self.export_results, state=tk.DISABLED) + self.export_button.pack(side=tk.LEFT, padx=5) + + def _create_result_table(self, parent): + """创建结果表格""" + # 创建 Treeview + columns = ("材料名称", "匹配的MaterialName", "负责人", "匹配状态") + self.tree = ttk.Treeview(parent, columns=columns, show="headings", height=10) + + # 设置列标题和宽度 + self.tree.heading("材料名称", text="材料名称") + self.tree.heading("匹配的MaterialName", text="匹配的MaterialName") + self.tree.heading("负责人", text="负责人") + self.tree.heading("匹配状态", text="匹配状态") + + self.tree.column("材料名称", width=300) + self.tree.column("匹配的MaterialName", width=300) + self.tree.column("负责人", width=150) + self.tree.column("匹配状态", width=100) + + # 添加滚动条 + scrollbar_y = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.tree.yview) + scrollbar_x = ttk.Scrollbar(parent, orient=tk.HORIZONTAL, command=self.tree.xview) + + self.tree.configure(yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set) + + # 布局 + self.tree.grid(row=0, column=0, sticky="nsew") + scrollbar_y.grid(row=0, column=1, sticky="ns") + scrollbar_x.grid(row=1, column=0, sticky="ew") + + parent.rowconfigure(0, weight=1) + parent.columnconfigure(0, weight=1) + + def _create_log_panel(self, parent): + """创建日志面板""" + self.log_text = LogText(parent, height=8, readonly=True) + self.log_text.pack(fill=tk.BOTH, expand=True) + + def _on_source_mode_change(self): + """数据源模式切换""" + if self.source_mode.get() == "existing": + self.existing_excel_frame.grid(row=0, column=0, columnspan=2, sticky="ew") + self.production_id_frame.grid_remove() + else: + self.existing_excel_frame.grid_remove() + self.production_id_frame.grid(row=0, column=0, columnspan=2, sticky="ew") + + def start_validation(self): + """开始校验""" + # 验证输入 + output_file = self.output_file_selector.get() + if not output_file: + messagebox.showerror("错误", "请指定输出文件路径") + return + + # 获取输入文件 + if self.source_mode.get() == "existing": + input_file = self.existing_excel_selector.get() + if not input_file: + messagebox.showerror("错误", "请选择现有 Excel 文件") + return + if not os.path.exists(input_file): + messagebox.showerror("错误", f"文件不存在:{input_file}") + return + production_id_file = None + else: + production_id_file = self.production_id_selector.get() + if not production_id_file: + messagebox.showerror("错误", "请选择 ProductionID 文件") + return + if not os.path.exists(production_id_file): + messagebox.showerror("错误", f"文件不存在:{production_id_file}") + return + input_file = None + + # 确保输出目录存在 + output_dir = os.path.dirname(output_file) + if output_dir and not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) + + # 更新 UI 状态 + self.validating = True + self.start_button.config(state=tk.DISABLED) + self.log_text.clear() + self.log_text.info("开始物料校验...") + + # 清空结果表格 + for item in self.tree.get_children(): + self.tree.delete(item) + + # 在后台线程中执行校验 + validation_thread = threading.Thread( + target=self._validation_worker, + args=(input_file, production_id_file, output_file), + daemon=True + ) + validation_thread.start() + + def _validation_worker(self, input_file: str, production_id_file: str, output_file: str): + """校验工作线程""" + try: + # 导入校验器 + from utils.material_status_validator import MaterialStatusValidator + + # 创建校验器实例(需要 ERP 凭据,因为可能需要登录系统) + validator = MaterialStatusValidator( + username=self.config.get('erp.username'), + password=self.config.get('erp.password'), + headless=self.config.get('browser.headless', True), + verbose=True + ) + + # 捕获 stdout 输出 + captured_output = StringIO() + + # 执行校验并捕获输出 + with redirect_stdout(captured_output): + if self.source_mode.get() == "existing": + result = validator.validate_from_existing_excel( + excel_file=input_file, + output_file=output_file + ) + else: + result = validator.validate( + production_id_file=production_id_file, + merged_excel_file=None, # 将在内部生成 + output_file=output_file + ) + + # 获取捕获的输出并显示到日志 + output_text = captured_output.getvalue() + if output_text: + for line in output_text.split('\n'): + if line.strip(): + self._update_log(line, "INFO") + + if result: + self._update_log(f"校验完成,结果已保存到:{output_file}", "SUCCESS") + # 加载结果显示 + self._load_results(output_file) + else: + self._update_log("校验失败", "ERROR") + + except Exception as e: + self._update_log(f"校验过程中发生错误:{str(e)}", "ERROR") + finally: + # 更新 UI 状态 + self.after(0, self._validation_complete) + + def _validation_complete(self): + """校验完成后的 UI 更新""" + self.validating = False + self.start_button.config(state=tk.NORMAL) + + def _load_results(self, file_path: str): + """加载校验结果到表格""" + try: + df = pd.read_excel(file_path) + + # 在主线程中更新表格 + def update_table(): + for _, row in df.iterrows(): + self.tree.insert("", tk.END, values=( + row.get('材料名称', ''), + row.get('匹配的MaterialName', ''), + row.get('负责人', ''), + row.get('匹配状态', '') + )) + + if len(df) > 0: + self.export_button.config(state=tk.NORMAL) + self._update_log(f"已加载 {len(df)} 条结果", "INFO") + + self.after(0, update_table) + + except Exception as e: + self._update_log(f"加载结果失败:{str(e)}", "ERROR") + + def _update_log(self, message: str, level: str = "INFO"): + """线程安全的日志更新""" + def update(): + if level == "INFO": + self.log_text.info(message) + elif level == "SUCCESS": + self.log_text.success(message) + elif level == "WARNING": + self.log_text.warning(message) + elif level == "ERROR": + self.log_text.error(message) + + self.after(0, update) + + def export_results(self): + """导出结果到 Excel""" + output_file = self.output_file_selector.get() + + if not output_file: + output_file = filedialog.asksaveasfilename( + title="保存结果", + defaultextension=".xlsx", + filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")] + ) + + if not output_file: + return + + try: + # 收集表格数据 + data = [] + for item in self.tree.get_children(): + values = self.tree.item(item)['values'] + data.append(values) + + if not data: + messagebox.showwarning("警告", "没有数据可导出") + return + + # 创建 DataFrame 并保存 + df = pd.DataFrame(data, columns=["材料名称", "匹配的MaterialName", "负责人", "匹配状态"]) + df.to_excel(output_file, index=False) + + messagebox.showinfo("成功", f"结果已导出到:{output_file}") + self.log_text.success(f"结果已导出到:{output_file}") + + except Exception as e: + messagebox.showerror("错误", f"导出失败:{str(e)}") + self.log_text.error(f"导出失败:{str(e)}") diff --git a/gui/settings_tab.py b/gui/settings_tab.py new file mode 100644 index 0000000..3eb3b58 --- /dev/null +++ b/gui/settings_tab.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +设置标签页 + +管理系统配置(ERP、数据库、浏览器、路径等)。 +""" + +import tkinter as tk +from tkinter import ttk, messagebox +import pyodbc +from gui.config_manager import ConfigManager + + +class SettingsTab(ttk.Frame): + """设置标签页""" + + def __init__(self, parent, config: ConfigManager): + """ + 初始化设置标签页 + + Args: + parent: 父容器 + config: 配置管理器 + """ + super().__init__(parent) + self.config = config + self.create_widgets() + self.load_settings() + + def create_widgets(self): + """创建界面组件""" + # 创建主容器,带滚动条 + canvas = tk.Canvas(self) + scrollbar = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) + scrollable_frame = ttk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + # ERP 配置组 + self._create_erp_group(scrollable_frame) + + # 数据库配置组 + self._create_database_group(scrollable_frame) + + # 浏览器配置组 + self._create_browser_group(scrollable_frame) + + # 路径配置组 + self._create_paths_group(scrollable_frame) + + # 处理配置组 + self._create_extraction_group(scrollable_frame) + + # 按钮区域 + button_frame = ttk.Frame(scrollable_frame) + button_frame.grid(row=5, column=0, columnspan=2, pady=20, sticky="ew") + + ttk.Button(button_frame, text="测试 ERP 连接", command=self.test_erp_connection).pack(side="left", padx=5) + ttk.Button(button_frame, text="测试数据库连接", command=self.test_db_connection).pack(side="left", padx=5) + ttk.Button(button_frame, text="保存设置", command=self.save_settings).pack(side="left", padx=5) + ttk.Button(button_frame, text="恢复默认", command=self.reset_defaults).pack(side="left", padx=5) + + # 布局 + canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + + def _create_erp_group(self, parent): + """创建 ERP 配置组""" + group = ttk.LabelFrame(parent, text="ERP 系统配置", padding=10) + group.grid(row=0, column=0, columnspan=2, pady=10, padx=10, sticky="ew") + + # URL + ttk.Label(group, text="ERP URL:").grid(row=0, column=0, sticky="w", pady=5) + self.erp_url_var = tk.StringVar() + ttk.Entry(group, textvariable=self.erp_url_var, width=50).grid(row=0, column=1, pady=5, sticky="ew") + + # 用户名 + ttk.Label(group, text="用户名:").grid(row=1, column=0, sticky="w", pady=5) + self.erp_username_var = tk.StringVar() + ttk.Entry(group, textvariable=self.erp_username_var, width=50).grid(row=1, column=1, pady=5, sticky="ew") + + # 密码 + ttk.Label(group, text="密码:").grid(row=2, column=0, sticky="w", pady=5) + self.erp_password_var = tk.StringVar() + entry = ttk.Entry(group, textvariable=self.erp_password_var, width=50, show="*") + entry.grid(row=2, column=1, pady=5, sticky="ew") + + group.columnconfigure(1, weight=1) + + def _create_database_group(self, parent): + """创建数据库配置组""" + group = ttk.LabelFrame(parent, text="数据库配置", padding=10) + group.grid(row=1, column=0, columnspan=2, pady=10, padx=10, sticky="ew") + + # 服务器 + ttk.Label(group, text="服务器:").grid(row=0, column=0, sticky="w", pady=5) + self.db_server_var = tk.StringVar() + ttk.Entry(group, textvariable=self.db_server_var, width=50).grid(row=0, column=1, pady=5, sticky="ew") + + # 数据库名 + ttk.Label(group, text="数据库:").grid(row=1, column=0, sticky="w", pady=5) + self.db_name_var = tk.StringVar() + ttk.Entry(group, textvariable=self.db_name_var, width=50).grid(row=1, column=1, pady=5, sticky="ew") + + # 用户名 + ttk.Label(group, text="用户名:").grid(row=2, column=0, sticky="w", pady=5) + self.db_username_var = tk.StringVar() + ttk.Entry(group, textvariable=self.db_username_var, width=50).grid(row=2, column=1, pady=5, sticky="ew") + + # 密码 + ttk.Label(group, text="密码:").grid(row=3, column=0, sticky="w", pady=5) + self.db_password_var = tk.StringVar() + entry = ttk.Entry(group, textvariable=self.db_password_var, width=50, show="*") + entry.grid(row=3, column=1, pady=5, sticky="ew") + + group.columnconfigure(1, weight=1) + + def _create_browser_group(self, parent): + """创建浏览器配置组""" + group = ttk.LabelFrame(parent, text="浏览器设置", padding=10) + group.grid(row=2, column=0, pady=10, padx=10, sticky="ew") + + self.browser_headless_var = tk.BooleanVar() + ttk.Checkbutton(group, text="无头模式 (不显示浏览器)", variable=self.browser_headless_var).grid(row=0, column=0, sticky="w", pady=5) + + self.browser_ignore_https_var = tk.BooleanVar() + ttk.Checkbutton(group, text="忽略 HTTPS 错误", variable=self.browser_ignore_https_var).grid(row=1, column=0, sticky="w", pady=5) + + self.browser_auto_close_var = tk.BooleanVar() + ttk.Checkbutton(group, text="操作完成后自动关闭浏览器", variable=self.browser_auto_close_var).grid(row=2, column=0, sticky="w", pady=5) + + def _create_paths_group(self, parent): + """创建路径配置组""" + group = ttk.LabelFrame(parent, text="路径设置", padding=10) + group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew") + + from gui.widgets import FileSelector + + # 数据目录 + ttk.Label(group, text="数据目录:").grid(row=0, column=0, sticky="w", pady=5) + self.data_dir_selector = FileSelector( + group, + label_text="", + file_type="directory", + initial_dir="D:/python/playwrite/data/" + ) + self.data_dir_selector.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5) + + # 默认输出文件 + ttk.Label(group, text="默认输出文件:").grid(row=2, column=0, sticky="w", pady=5) + self.default_output_var = tk.StringVar() + ttk.Entry(group, textvariable=self.default_output_var, width=40).grid(row=3, column=0, columnspan=2, sticky="ew", pady=5) + + group.columnconfigure(0, weight=1) + + def _create_extraction_group(self, parent): + """创建处理配置组""" + group = ttk.LabelFrame(parent, text="数据提取设置", padding=10) + group.grid(row=3, column=0, columnspan=2, pady=10, padx=10, sticky="ew") + + # 批次大小 + ttk.Label(group, text="批次大小:").grid(row=0, column=0, sticky="w", pady=5) + self.batch_size_var = tk.IntVar(value=100) + ttk.Spinbox(group, from_=10, to=500, textvariable=self.batch_size_var, width=10).grid(row=0, column=1, sticky="w", pady=5) + + # 详细日志 + self.verbose_var = tk.BooleanVar() + ttk.Checkbutton(group, text="启用详细日志", variable=self.verbose_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=5) + + # 自动转换 + self.auto_convert_var = tk.BooleanVar() + ttk.Checkbutton(group, text="自动转换 Excel 格式", variable=self.auto_convert_var).grid(row=2, column=0, columnspan=2, sticky="w", pady=5) + + # 合并批次 + self.merge_batches_var = tk.BooleanVar() + ttk.Checkbutton(group, text="自动合并批次数据", variable=self.merge_batches_var).grid(row=3, column=0, columnspan=2, sticky="w", pady=5) + + def load_settings(self): + """从配置加载设置到界面""" + # ERP 设置 + self.erp_url_var.set(self.config.get('erp.url', '')) + self.erp_username_var.set(self.config.get('erp.username', '')) + self.erp_password_var.set(self.config.get('erp.password', '')) + + # 数据库设置 + self.db_server_var.set(self.config.get('database.server', '')) + self.db_name_var.set(self.config.get('database.database', '')) + self.db_username_var.set(self.config.get('database.username', '')) + self.db_password_var.set(self.config.get('database.password', '')) + + # 浏览器设置 + self.browser_headless_var.set(self.config.get('browser.headless', True)) + self.browser_ignore_https_var.set(self.config.get('browser.ignore_https_errors', True)) + self.browser_auto_close_var.set(self.config.get('browser.auto_close', True)) + + # 路径设置 + self.data_dir_selector.set(self.config.get('paths.data_dir', '')) + self.default_output_var.set(self.config.get('paths.default_output', '')) + + # 处理设置 + self.batch_size_var.set(self.config.get('extraction.batch_size', 100)) + self.verbose_var.set(self.config.get('extraction.verbose', True)) + self.auto_convert_var.set(self.config.get('extraction.auto_convert', True)) + self.merge_batches_var.set(self.config.get('extraction.merge_batches', True)) + + def save_settings(self): + """保存界面设置到配置""" + # ERP 设置 + self.config.set('erp.url', self.erp_url_var.get()) + self.config.set('erp.username', self.erp_username_var.get()) + self.config.set('erp.password', self.erp_password_var.get()) + + # 数据库设置 + self.config.set('database.server', self.db_server_var.get()) + self.config.set('database.database', self.db_name_var.get()) + self.config.set('database.username', self.db_username_var.get()) + self.config.set('database.password', self.db_password_var.get()) + + # 浏览器设置 + self.config.set('browser.headless', self.browser_headless_var.get()) + self.config.set('browser.ignore_https_errors', self.browser_ignore_https_var.get()) + self.config.set('browser.auto_close', self.browser_auto_close_var.get()) + + # 路径设置 + self.config.set('paths.data_dir', self.data_dir_selector.get()) + self.config.set('paths.default_output', self.default_output_var.get()) + + # 处理设置 + self.config.set('extraction.batch_size', self.batch_size_var.get()) + self.config.set('extraction.verbose', self.verbose_var.get()) + self.config.set('extraction.auto_convert', self.auto_convert_var.get()) + self.config.set('extraction.merge_batches', self.merge_batches_var.get()) + + # 保存到文件 + if self.config.save(): + messagebox.showinfo("成功", "设置已保存") + else: + messagebox.showerror("错误", "保存设置失败") + + def test_db_connection(self): + """测试数据库连接""" + try: + conn_str = ( + f"DRIVER={{ODBC Driver 18 for SQL Server}};" + f"SERVER={self.db_server_var.get()};" + f"DATABASE={self.db_name_var.get()};" + f"UID={self.db_username_var.get()};" + f"PWD={self.db_password_var.get()};" + f"TrustServerCertificate=yes;" + ) + + conn = pyodbc.connect(conn_str, timeout=5) + conn.close() + messagebox.showinfo("成功", "数据库连接测试成功!") + + except Exception as e: + messagebox.showerror("错误", f"数据库连接失败:\n{str(e)}") + + def test_erp_connection(self): + """测试 ERP 连接""" + # ERP 连接测试需要实际启动浏览器 + messagebox.showinfo("提示", "ERP 连接测试将在数据提取功能中自动验证") + + def reset_defaults(self): + """恢复默认设置""" + if messagebox.askyesno("确认", "确定要恢复默认设置吗?"): + self.config.reset_to_defaults() + self.load_settings() + messagebox.showinfo("成功", "已恢复默认设置") diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py new file mode 100644 index 0000000..d2451b9 --- /dev/null +++ b/gui/widgets/__init__.py @@ -0,0 +1,10 @@ +""" +GUI 自定义组件模块 + +提供可复用的 UI 组件。 +""" + +from .file_selector import FileSelector +from .log_text import LogText + +__all__ = ['FileSelector', 'LogText'] diff --git a/gui/widgets/file_selector.py b/gui/widgets/file_selector.py new file mode 100644 index 0000000..c9b87fe --- /dev/null +++ b/gui/widgets/file_selector.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +文件选择器组件 + +提供文件/目录选择功能的组合组件。 +""" + +import tkinter as tk +from tkinter import filedialog, ttk +from typing import Optional, Callable + + +class FileSelector(ttk.Frame): + """文件选择器组件""" + + def __init__( + self, + parent, + label_text: str = "", + file_type: str = "file", + file_types: list = None, + initial_dir: str = "", + on_change: Optional[Callable] = None + ): + """ + 初始化文件选择器 + + Args: + parent: 父容器 + label_text: 标签文本 + file_type: 选择类型 ('file' 或 'directory') + file_types: 文件类型过滤器,如 [("Excel", "*.xlsx")] + initial_dir: 初始目录 + on_change: 值改变时的回调函数 + """ + super().__init__(parent) + + self.file_type = file_type + self.file_types = file_types or [("所有文件", "*.*")] + self.initial_dir = initial_dir + self.on_change = on_change + + # 创建标签 + if label_text: + self.label = ttk.Label(self, text=label_text) + self.label.grid(row=0, column=0, sticky="w", padx=(0, 5)) + + # 创建输入框 + self.entry_var = tk.StringVar() + self.entry = ttk.Entry(self, textvariable=self.entry_var, width=50) + self.entry.grid(row=0, column=1, sticky="ew", padx=5) + + # 创建浏览按钮 + self.browse_button = ttk.Button(self, text="浏览...", command=self._browse) + self.browse_button.grid(row=0, column=2, padx=5) + + # 配置列权重 + self.columnconfigure(1, weight=1) + + def _browse(self) -> None: + """打开文件/目录选择对话框""" + current_path = self.entry_var.get() or self.initial_dir + + if self.file_type == "file": + path = filedialog.askopenfilename( + title="选择文件", + initialdir=current_path, + filetypes=self.file_types + ) + else: # directory + path = filedialog.askdirectory( + title="选择目录", + initialdir=current_path + ) + + if path: + self.entry_var.set(path) + if self.on_change: + self.on_change(path) + + def get(self) -> str: + """获取当前选择的路径""" + return self.entry_var.get() + + def set(self, path: str) -> None: + """设置路径""" + self.entry_var.set(path) + + def clear(self) -> None: + """清空路径""" + self.entry_var.set("") + + def is_empty(self) -> bool: + """检查是否为空""" + return not self.entry_var.get() diff --git a/gui/widgets/log_text.py b/gui/widgets/log_text.py new file mode 100644 index 0000000..8c9a6a0 --- /dev/null +++ b/gui/widgets/log_text.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +日志文本框组件 + +带颜色支持的日志显示组件。 +""" + +import tkinter as tk +from datetime import datetime + + +class LogText(tk.Frame): + """日志文本框组件(带滚动条)""" + + # 日志级别颜色配置 + LOG_COLORS = { + 'INFO': '#000000', # 黑色 + 'SUCCESS': '#008000', # 绿色 + 'WARNING': '#FF8C00', # 深橙色 + 'ERROR': '#FF0000', # 红色 + 'DEBUG': '#808080', # 灰色 + } + + def __init__(self, parent, readonly=True, **kwargs): + """ + 初始化日志文本框 + + Args: + parent: 父容器 + readonly: 是否为只读模式 + **kwargs: 其他参数 + """ + super().__init__(parent) + + # 创建文本框和滚动条 + self.text = tk.Text(self, **kwargs) + self.scrollbar = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.text.yview) + self.text.configure(yscrollcommand=self.scrollbar.set) + + # 布局 + self.text.grid(row=0, column=0, sticky="nsew") + self.scrollbar.grid(row=0, column=1, sticky="ns") + + # 配置网格权重 + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # 标记 tags 是否已配置 + self._tags_configured = False + + # 设置为只读模式 + if readonly: + self._make_readonly() + + def _ensure_tags_configured(self): + """确保文本标签已配置(延迟配置,在首次使用时)""" + if not self._tags_configured: + try: + for level, color in self.LOG_COLORS.items(): + self.text.tag_config(level.lower(), foreground=color) + self._tags_configured = True + except Exception: + # 如果配置失败,标记为已尝试,避免重复尝试 + self._tags_configured = True + + def _make_readonly(self): + """通过绑定事件使文本框只读""" + # 允许复制、全选等常用操作,阻止其他编辑操作 + self.text.bind('', self._handle_key) + self.text.bind('', self._allow_click) # 允许左键点击选择 + + def _handle_key(self, event): + """处理按键事件,允许复制操作,阻止编辑""" + # 允许的快捷键 + allowed_keys = [ + 'Control-c', # 复制 + 'Control-C', # 复制(大写) + 'Control-a', # 全选 + 'Control-A', # 全选(大写) + 'Control-x', # 剪切(虽然剪不了,但不报错) + 'Control-X', + ] + + # 检查是否是允许的快捷键 + key_sym = event.keysym + state = event.state + + # 检查 Ctrl 组合键 + if state & 0x4: # Ctrl 键被按下 + full_key = f"Control-{key_sym}" + if full_key in allowed_keys: + return # 允许执行 + + # 其他所有按键都阻止 + return 'break' + + def _allow_click(self, event): + """允许点击和选择文本""" + # 不打断事件,允许正常的选择操作 + return + + def log(self, message: str, level: str = 'INFO') -> None: + """ + 添加日志消息 + + Args: + message: 日志消息 + level: 日志级别 (INFO, SUCCESS, WARNING, ERROR, DEBUG) + """ + # 确保 tags 已配置 + self._ensure_tags_configured() + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + log_message = f"[{timestamp}] [{level}] {message}\n" + + # 插入文本 + tag = level.lower() if self._tags_configured else None + if tag: + try: + self.text.insert('end', log_message, (tag,)) + except Exception: + # 如果带标签插入失败,尝试不带标签 + self.text.insert('end', log_message) + else: + self.text.insert('end', log_message) + + # 自动滚动到底部 + self.text.see('end') + + def info(self, message: str) -> None: + """添加 INFO 级别日志""" + self.log(message, 'INFO') + + def success(self, message: str) -> None: + """添加 SUCCESS 级别日志""" + self.log(message, 'SUCCESS') + + def warning(self, message: str) -> None: + """添加 WARNING 级别日志""" + self.log(message, 'WARNING') + + def error(self, message: str) -> None: + """添加 ERROR 级别日志""" + self.log(message, 'ERROR') + + def debug(self, message: str) -> None: + """添加 DEBUG 级别日志""" + self.log(message, 'DEBUG') + + def clear(self) -> None: + """清空日志""" + self.text.delete('1.0', 'end') + + def save_to_file(self, file_path: str) -> bool: + """ + 保存日志到文件 + + Args: + file_path: 保存路径 + + Returns: + 是否成功 + """ + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(self.text.get('1.0', 'end-1c')) + return True + except Exception as e: + self.error(f"保存日志失败: {e}") + return False + + # 委托其他常用方法到内部 text 组件 + def pack(self, **kwargs): + """Pack 布局""" + super().pack(**kwargs) + + def grid(self, **kwargs): + """Grid 布局""" + super().grid(**kwargs) diff --git a/gui/widgets/progress_dialog.py b/gui/widgets/progress_dialog.py new file mode 100644 index 0000000..9a3ac41 --- /dev/null +++ b/gui/widgets/progress_dialog.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +进度对话框组件 + +用于显示长时间运行操作的进度。 +""" + +import tkinter as tk +from tkinter import ttk +from typing import Optional, Callable + + +class ProgressDialog: + """进度对话框""" + + def __init__( + self, + parent, + title: str = "处理中...", + message: str = "请稍候", + can_cancel: bool = True, + on_cancel: Optional[Callable] = None + ): + """ + 初始化进度对话框 + + Args: + parent: 父窗口 + title: 对话框标题 + message: 显示消息 + can_cancel: 是否可以取消 + on_cancel: 取消回调函数 + """ + self.parent = parent + self.can_cancel = can_cancel + self.on_cancel = on_cancel + self.cancelled = False + + # 创建对话框 + self.dialog = tk.Toplevel(parent) + self.dialog.title(title) + self.dialog.resizable(False, False) + self.dialog.transient(parent) + self.dialog.grab_set() + + # 居中显示 + self._center() + + # 创建内容 + self._create_widgets(message) + + def _center(self): + """将对话框居中显示""" + self.dialog.update_idletasks() + width = 400 + height = 150 + x = (self.dialog.winfo_screenwidth() // 2) - (width // 2) + y = (self.dialog.winfo_screenheight() // 2) - (height // 2) + self.dialog.geometry(f"{width}x{height}+{x}+{y}") + + def _create_widgets(self, message: str): + """创建对话框组件""" + # 消息标签 + self.message_label = ttk.Label(self.dialog, text=message, wraplength=380) + self.message_label.pack(pady=(20, 10), padx=20) + + # 进度条 + self.progress = ttk.Progressbar( + self.dialog, + mode='indeterminate', + length=360 + ) + self.progress.pack(pady=10, padx=20) + self.progress.start(10) + + # 取消按钮 + if self.can_cancel: + button_frame = ttk.Frame(self.dialog) + button_frame.pack(pady=10) + + self.cancel_button = ttk.Button( + button_frame, + text="取消", + command=self._on_cancel + ) + self.cancel_button.pack() + + def _on_cancel(self): + """处理取消操作""" + self.cancelled = True + if self.on_cancel: + self.on_cancel() + self.close() + + def update_message(self, message: str): + """更新显示消息""" + self.message_label.config(text=message) + self.dialog.update_idletasks() + + def set_progress(self, value: int, maximum: int = 100): + """ + 设置进度值 + + Args: + value: 当前进度值 + maximum: 最大值 + """ + self.progress.config(mode='determinate', maximum=maximum) + self.progress['value'] = value + self.dialog.update_idletasks() + + def close(self): + """关闭对话框""" + self.progress.stop() + self.dialog.destroy() + + def is_cancelled(self) -> bool: + """检查是否已取消""" + return self.cancelled diff --git a/main_ui.py b/main_ui.py new file mode 100644 index 0000000..563e8c1 --- /dev/null +++ b/main_ui.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +ERP 自动化工具 - GUI 版本 + +这是一个基于 Tkinter 的图形用户界面应用程序, +用于自动化 ERP 系统(用友 YonBIP)的数据提取、校验和查询任务。 + +运行方式: + python main_ui.py + +功能: + - 数据提取:从 ERP 系统提取备料计划数据 + - 物料校验:校验物料状态并匹配待删除物料 + - 数据查询:查询生产订单号等信息 + - 设置管理:管理系统配置 +""" + +import tkinter as tk +from gui.main_window import MainWindow + + +def main(): + """主函数""" + # 创建根窗口 + root = tk.Tk() + + # 创建主窗口 + app = MainWindow(root) + + # 启动主事件循环 + root.mainloop() + + +if __name__ == "__main__": + main()