Files
playwrite/gui/data_extraction_tab.py
Misaka 6f8df2f2e6 feat: add Production ID input widget and UI configuration
- Add ProductionIdInput widget with placeholder and multi-line support
- Add UIConfig class for font family, font size, and input width settings
- Refactor data extraction tab to use horizontal PanedWindow layout
  - Left panel: Production ID text input (draggable width)
  - Right panel: control panel and log output
- Share Production IDs between data extraction and material validation tabs
- Add UI settings group in settings page (font selection, size, input width)
- For User users: automatically use shared Production IDs, simplified UI
- Apply font settings to input and log widgets
- Use sashpos() to set initial pane width correctly

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 21:47:17 +08:00

372 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, ProductionIdInput
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, main_window=None):
"""
初始化数据提取标签页
Args:
parent: 父容器
config: 配置管理器
main_window: 主窗口引用,用于共享 Production ID 数据
"""
super().__init__(parent)
self.config = config
self.main_window = main_window
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()
# 应用字体设置
self._apply_ui_config()
# 稍后显示就绪消息
try:
self.log_text.info("数据提取标签页已就绪")
except:
pass # 如果窗口还未完全就绪,忽略错误
def create_widgets(self):
"""创建界面组件"""
# 主容器 - 使用水平 PanedWindow 分割左右部分
horizontal_paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
horizontal_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 左侧Production ID 输入面板
left_panel = ttk.Frame(horizontal_paned)
horizontal_paned.add(left_panel, weight=0)
# 右侧:主面板(控制面板 + 日志)
right_panel = ttk.Frame(horizontal_paned)
horizontal_paned.add(right_panel, weight=1)
self._create_left_panel(left_panel)
self._create_right_panel(right_panel)
# 保存 PanedWindow 引用,后续用于设置分隔条位置
self.horizontal_paned = horizontal_paned
# 设置默认宽度(使用 after 确保在渲染后设置)
input_width = self.config.get("ui.production_id_input_width", 20)
# 字符宽度约 8 像素
self.after(100, lambda: self._set_pane_width(input_width * 8))
def _create_left_panel(self, parent):
"""创建左侧 Production ID 输入面板"""
# 创建带标题的框架
input_group = ttk.LabelFrame(parent, text="Production ID", padding=10)
input_group.pack(fill=tk.BOTH, expand=True)
# 创建 Production ID 输入控件
self.production_id_input = ProductionIdInput(
input_group,
placeholder="每行输入一个 Production ID\n\n示例:\n26B848\n26B849"
)
self.production_id_input.pack(fill=tk.BOTH, expand=True)
# 绑定变化事件:当文本框失去焦点时更新共享 Production ID
self.production_id_input.text_widget.bind("<FocusOut>", self._on_production_ids_changed)
def _create_right_panel(self, parent):
"""创建右侧主面板"""
# 主容器 - 使用垂直 PanedWindow 分割上下部分
main_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL)
main_paned.pack(fill=tk.BOTH, expand=True)
# 上部:控制面板
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):
"""创建控制面板"""
# 输出文件选择
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.headless_var = tk.BooleanVar(value=self.config.get("erp.headless", True))
ttk.Checkbutton(
options_group, text="无头模式 (不显示浏览器)", variable=self.headless_var
).grid(row=0, column=0, 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 _apply_ui_config(self):
"""应用 UI 配置(字体等)"""
try:
font_family = self.config.get("ui.font_family", "Microsoft YaHei UI")
font_size = self.config.get("ui.font_size", 10)
# 应用到 Production ID 输入控件
self.production_id_input.apply_font(font_family, font_size)
# 应用到日志控件(如果支持)
if hasattr(self.log_text, 'apply_font'):
self.log_text.apply_font(font_family, font_size)
except Exception as e:
# 如果应用字体失败,不影响主流程
pass
def _set_pane_width(self, width: int):
"""设置左侧 pane 的宽度
Args:
width: 宽度(像素)
"""
try:
# 使用 sashpos 方法设置分隔条位置
# 参数 0 表示第一个分隔条(索引从 0 开始)
self.horizontal_paned.sashpos(0, width)
except Exception as e:
# 如果设置失败,不影响主流程
pass
def start_extraction(self):
"""开始数据提取"""
# 获取 Production ID 列表
production_ids = self.production_id_input.get()
if not production_ids:
messagebox.showerror("错误", "请输入至少一个 Production ID")
return
output_file = self.output_file_selector.get()
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(f"开始数据提取... ({len(production_ids)} 个 Production ID)")
# 在后台线程中执行提取
self.extraction_thread = threading.Thread(
target=self._extraction_worker, args=(production_ids, 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, production_ids: list[str], output_file: str):
"""提取工作线程"""
import tempfile
try:
# 创建临时文件保存 Production ID 列表
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
temp_file = f.name
f.write('\n'.join(production_ids))
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.config.get("extraction.verbose", True),
batch_size=self.config.get("extraction.batch_size", 100),
enable_db_persistence=self.config.get("extraction.enable_db_persistence", False),
)
# 创建实时输出流,每次写入立即更新 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=temp_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")
finally:
# 删除临时文件
try:
os.unlink(temp_file)
except:
pass
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)
def _on_production_ids_changed(self, event=None):
"""Production ID 变化时的回调"""
if self.main_window:
production_ids = self.production_id_input.get()
self.main_window.update_shared_production_ids(production_ids)
def reload_config(self):
"""配置更新后重新应用 UI 设置"""
self._apply_ui_config()
# 通知主窗口当前的 Production ID
self._on_production_ids_changed()