Files
playwrite/gui/settings_tab.py
Misaka caac411e17 feat: add MySQL database support alongside SQL Server
This commit implements multi-database support, allowing the system to switch
between SQL Server and MySQL databases seamlessly.

## New Features
- Database type selection (SQL Server or MySQL) via configuration
- Automatic table name conversion between formats ([dbo].[table] → dbo_table)
- Automatic parameter placeholder handling (? for SQL Server, %s for MySQL)
- GUI settings tab now includes database type dropdown and MySQL configuration

## Database Abstraction Layer
- db/base_connection.py: Abstract base class for database connections
- db/sqlserver_connection.py: SQL Server implementation
- db/mysql_connection.py: MySQL implementation using mysql-connector-python
- db/connection_factory.py: Factory pattern for creating connections
- db/table_name_converter.py: Table name format conversion utility

## DAO Base Class
- db/base_dao.py: Base DAO with helper methods for SQL conversion and placeholders

## Updated Components
- config/schema.py: Extended with DatabaseType enum and MySQL/SQLServer config classes
- config/defaults.py: Added MySQL default configuration
- config/loader.py: Updated to handle new database structure
- db/connection.py: Refactored to use factory pattern and load user config
- All DAO files: Updated to inherit from BaseDAO with automatic conversion

## Dependencies
- Added mysql-connector-python>=8.0.0 to requirements.txt

## Configuration
To use MySQL, set db_type to "mysql" in config/user_settings.json:
{
  "database": {
    "db_type": "mysql",
    "mysql": {
      "host": "192.168.31.83",
      "port": 3306,
      "database": "BLD_DB",
      "username": "remote_user",
      "password": "3.1415926Beeke"
    }
  }
}

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-09 21:50:54 +08:00

483 lines
20 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 tkinter as tk
from tkinter import ttk, messagebox
import pyodbc
from gui.config_manager import ConfigManager
from config.schema import DatabaseType
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)
# 校验配置组
self._create_validation_group(scrollable_frame)
# 按钮区域
button_frame = ttk.Frame(scrollable_frame)
button_frame.grid(row=6, 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_type_var = tk.StringVar()
db_type_combo = ttk.Combobox(
group,
textvariable=self.db_type_var,
values=["sqlserver", "mysql"],
state="readonly",
width=30,
)
db_type_combo.grid(row=0, column=1, sticky="w", pady=5)
db_type_combo.bind("<<ComboboxSelected>>", self._on_db_type_changed)
# SQL Server 配置
self.sqlserver_frame = ttk.Frame(group)
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
ttk.Label(self.sqlserver_frame, text="服务器:").grid(row=0, column=0, sticky="w", pady=5)
self.db_server_var = tk.StringVar()
ttk.Entry(self.sqlserver_frame, textvariable=self.db_server_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
)
# MySQL 配置
self.mysql_frame = ttk.Frame(group)
ttk.Label(self.mysql_frame, text="主机:").grid(row=0, column=0, sticky="w", pady=5)
self.mysql_host_var = tk.StringVar()
ttk.Entry(self.mysql_frame, textvariable=self.mysql_host_var, width=50).grid(
row=0, column=1, pady=5, sticky="ew"
)
ttk.Label(self.mysql_frame, text="端口:").grid(row=1, column=0, sticky="w", pady=5)
self.mysql_port_var = tk.IntVar(value=3306)
ttk.Spinbox(
self.mysql_frame, from_=1, to=65535, textvariable=self.mysql_port_var, width=10
).grid(row=1, column=1, sticky="w", pady=5)
# 通用配置(两种数据库都需要)
ttk.Label(group, text="数据库:").grid(row=2, column=0, sticky="w", pady=5)
self.db_name_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_name_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_username_var = tk.StringVar()
ttk.Entry(group, textvariable=self.db_username_var, width=50).grid(
row=3, column=1, pady=5, sticky="ew"
)
ttk.Label(group, text="密码:").grid(row=4, 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=4, column=1, pady=5, sticky="ew")
group.columnconfigure(1, weight=1)
def _on_db_type_changed(self, event=None):
"""数据库类型改变时的回调"""
db_type = self.db_type_var.get()
if db_type == "mysql":
self.sqlserver_frame.grid_forget()
self.mysql_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
else:
self.mysql_frame.grid_forget()
self.sqlserver_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=5)
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)
# 数据库持久化
self.enable_db_persistence_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="保存到数据库", variable=self.enable_db_persistence_var
).grid(row=4, column=0, columnspan=2, sticky="w", pady=5)
def _create_validation_group(self, parent):
"""创建物料校验配置组"""
group = ttk.LabelFrame(parent, text="物料校验设置", padding=10)
group.grid(row=4, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# 数据源选择
ttk.Label(group, text="默认数据源:").grid(row=0, column=0, sticky="w", pady=5)
self.validation_data_source_var = tk.StringVar()
data_source_combo = ttk.Combobox(
group,
textvariable=self.validation_data_source_var,
values=["database_full", "database_filtered", "excel_existing", "excel_full"],
state="readonly",
width=30,
)
data_source_combo.grid(row=0, column=1, sticky="w", pady=5)
# 使用数据库
self.validation_use_database_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="使用数据库作为数据源", variable=self.validation_use_database_var
).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# 批次大小
ttk.Label(group, text="数据库批次大小:").grid(row=2, column=0, sticky="w", pady=5)
self.validation_batch_size_var = tk.IntVar(value=2000)
ttk.Spinbox(
group, from_=100, to=2000, textvariable=self.validation_batch_size_var, width=10
).grid(row=2, column=1, sticky="w", pady=5)
# 匹配模式
ttk.Label(group, text="匹配模式:").grid(row=3, column=0, sticky="w", pady=5)
self.validation_match_mode_var = tk.StringVar()
match_mode_combo = ttk.Combobox(
group,
textvariable=self.validation_match_mode_var,
values=["substring", "exact"],
state="readonly",
width=30,
)
match_mode_combo.grid(row=3, column=1, sticky="w", pady=5)
# CRUD 操作
self.validation_enable_crud_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="启用 CRUD 操作(管理待删除物料)", variable=self.validation_enable_crud_var
).grid(row=4, column=0, columnspan=2, sticky="w", pady=5)
# 默认负责人
ttk.Label(group, text="默认负责人:").grid(row=5, column=0, sticky="w", pady=5)
self.validation_default_manager_var = tk.StringVar()
ttk.Entry(group, textvariable=self.validation_default_manager_var, width=30).grid(
row=5, column=1, sticky="w", pady=5
)
group.columnconfigure(1, weight=1)
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", ""))
# 数据库设置
db_type = self.config.get("database.db_type", "sqlserver")
self.db_type_var.set(db_type)
if db_type == "mysql":
self.db_server_var.set(self.config.get("database.server", ""))
self.mysql_host_var.set(self.config.get("database.mysql.host", ""))
self.mysql_port_var.set(self.config.get("database.mysql.port", 3306))
else:
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._on_db_type_changed()
# 浏览器设置(已合并到 ERP 配置中)
self.browser_headless_var.set(self.config.get("erp.headless", True))
self.browser_ignore_https_var.set(
self.config.get("erp.ignore_https_errors", True)
)
self.browser_auto_close_var.set(self.config.get("erp.auto_close_browser", 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))
self.enable_db_persistence_var.set(self.config.get("extraction.enable_db_persistence", False))
# 校验设置
self.validation_data_source_var.set(self.config.get("validation.data_source", "database_full"))
self.validation_use_database_var.set(self.config.get("validation.use_database", True))
self.validation_batch_size_var.set(self.config.get("validation.batch_size", 2000))
self.validation_match_mode_var.set(self.config.get("validation.match_mode", "substring"))
self.validation_enable_crud_var.set(self.config.get("validation.enable_crud_operations", False))
self.validation_default_manager_var.set(self.config.get("validation.default_manager", ""))
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())
# 数据库设置
db_type = self.db_type_var.get()
self.config.set("database.db_type", db_type)
if db_type == "mysql":
# MySQL: 使用 host 字段
self.config.set("database.server", self.mysql_host_var.get())
self.config.set("database.mysql.host", self.mysql_host_var.get())
self.config.set("database.mysql.port", self.mysql_port_var.get())
else:
# SQL Server: 使用 server 字段
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())
# 浏览器设置(已合并到 ERP 配置中)
self.config.set("erp.headless", self.browser_headless_var.get())
self.config.set("erp.ignore_https_errors", self.browser_ignore_https_var.get())
self.config.set("erp.auto_close_browser", 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())
self.config.set("extraction.enable_db_persistence", self.enable_db_persistence_var.get())
# 校验设置
self.config.set("validation.data_source", self.validation_data_source_var.get())
self.config.set("validation.use_database", self.validation_use_database_var.get())
self.config.set("validation.batch_size", self.validation_batch_size_var.get())
self.config.set("validation.match_mode", self.validation_match_mode_var.get())
self.config.set("validation.enable_crud_operations", self.validation_enable_crud_var.get())
self.config.set("validation.default_manager", self.validation_default_manager_var.get())
# 保存到文件
if self.config.save():
messagebox.showinfo("成功", "设置已保存")
else:
messagebox.showerror("错误", "保存设置失败")
def test_db_connection(self):
"""测试数据库连接"""
db_type = self.db_type_var.get()
try:
if db_type == "mysql":
import mysql.connector
from mysql.connector import Error
conn = mysql.connector.connect(
host=self.mysql_host_var.get(),
port=self.mysql_port_var.get(),
database=self.db_name_var.get(),
user=self.db_username_var.get(),
password=self.db_password_var.get(),
connection_timeout=5
)
conn.close()
messagebox.showinfo("成功", "MySQL 数据库连接测试成功!")
else:
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("成功", "SQL Server 数据库连接测试成功!")
except ImportError:
if db_type == "mysql":
messagebox.showerror("错误", "未安装 mysql-connector-python请运行\npip install mysql-connector-python")
else:
messagebox.showerror("错误", "未安装 pyodbc请运行\npip install pyodbc")
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("成功", "已恢复默认设置")