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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-02-05 13:33:23 +08:00
parent c9e68a2bf0
commit 71621dc8a0
14 changed files with 1977 additions and 1 deletions

4
.gitignore vendored
View File

@@ -18,3 +18,7 @@ ProductionID.txt
# 测试脚本 # 测试脚本
tests/ tests/
# 用户配置文件(包含敏感信息)
config/user_settings.json
nul

40
config/user_settings.py Normal file
View File

@@ -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
}
}

7
gui/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
ERP 自动化工具 - GUI 模块
提供图形用户界面用于执行 ERP 系统自动化任务。
"""
__version__ = "1.0.0"

141
gui/config_manager.py Normal file
View File

@@ -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

258
gui/data_extraction_tab.py Normal file
View File

@@ -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)

306
gui/data_query_tab.py Normal file
View File

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

125
gui/main_window.py Normal file
View File

@@ -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 开发"
)

View File

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

279
gui/settings_tab.py Normal file
View File

@@ -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(
"<Configure>",
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("成功", "已恢复默认设置")

10
gui/widgets/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
"""
GUI 自定义组件模块
提供可复用的 UI 组件。
"""
from .file_selector import FileSelector
from .log_text import LogText
__all__ = ['FileSelector', 'LogText']

View File

@@ -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()

180
gui/widgets/log_text.py Normal file
View File

@@ -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('<Key>', self._handle_key)
self.text.bind('<Button-1>', 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)

View File

@@ -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

36
main_ui.py Normal file
View File

@@ -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()