Major changes: - Add dataclass-based configuration schema with validation (config/schema.py) - Create centralized config loader and default values (config/defaults.py, config/loader.py) - Remove duplicate database_config.py, merge into unified structure - Consolidate browser settings into ERP config - Add batch_size parameter support to extractor Bug fixes: - Fix settings save error by updating config paths (browser.* → erp.*) - Fix batch_size not being applied in data extraction Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
#!/usr/bin/env python
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
数据提取标签页
|
||
|
||
从 ERP 系统提取备料计划数据。
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import threading
|
||
import queue
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox
|
||
from pathlib import Path
|
||
from contextlib import redirect_stdout
|
||
from gui.widgets import FileSelector, LogText
|
||
from gui.config_manager import ConfigManager
|
||
from gui.progress import ProgressInfo, ProgressCalculator
|
||
from gui.utils import RealtimeOutput
|
||
|
||
|
||
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.progress_calculator = ProgressCalculator()
|
||
self.progress_queue = queue.Queue() # 进度更新队列
|
||
|
||
# 启动进度更新轮询
|
||
self._poll_progress_queue()
|
||
|
||
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('erp.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.status_label.config(text="正在登录...")
|
||
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(),
|
||
batch_size=self.config.get('extraction.batch_size', 100)
|
||
)
|
||
|
||
# 创建实时输出流,每次写入立即更新 GUI
|
||
realtime_output = RealtimeOutput(lambda line: self._update_log(line, "INFO"))
|
||
|
||
# 创建进度回调函数
|
||
def progress_callback(progress_info: ProgressInfo):
|
||
# 计算总体进度百分比
|
||
overall_percent = self.progress_calculator.calculate_overall_percent(progress_info)
|
||
self._update_progress(overall_percent, progress_info.message)
|
||
|
||
# 重定向 stdout 并执行提取(带进度回调)
|
||
with redirect_stdout(realtime_output):
|
||
result = self.extractor.extract(
|
||
production_id_file=input_file,
|
||
output_file=output_file,
|
||
progress_callback=progress_callback
|
||
)
|
||
|
||
if result and self.extracting:
|
||
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 _poll_progress_queue(self):
|
||
"""轮询进度队列,处理进度更新"""
|
||
try:
|
||
while True:
|
||
# 非阻塞地获取队列中的消息
|
||
try:
|
||
progress_data = self.progress_queue.get_nowait()
|
||
value, message = progress_data
|
||
self.progress_bar['value'] = value
|
||
self.status_label.config(text=message)
|
||
except queue.Empty:
|
||
break
|
||
finally:
|
||
# 继续轮询(每 50ms 检查一次)
|
||
self.after(50, self._poll_progress_queue)
|
||
|
||
def _update_progress(self, value: int, message: str):
|
||
"""线程安全的进度更新(通过队列)"""
|
||
try:
|
||
self.progress_queue.put_nowait((value, message))
|
||
except:
|
||
pass # 队列满时忽略
|
||
|
||
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)
|