fix: 优化文件保存对话框并启用原生窗口模式

- 添加 matplotlib.use('Agg') 设置非交互式后端
- 重构 save_file_with_dialog 函数,使用 PowerShell 原生对话框替代 Tkinter
- 解决 PyWebview/Tkinter 线程冲突导致的 PicklingError 问题
- 启用 native=True 原生窗口模式,提供更好的用户体验
This commit is contained in:
dmy
2026-01-07 01:03:46 +08:00
parent 61fa870778
commit 837158270e

197
gui.py
View File

@@ -4,6 +4,9 @@ import os
import sys
import tempfile
import matplotlib
matplotlib.use("Agg")
import matplotlib.backends.backend_svg
import matplotlib.pyplot as plt
import pandas as pd
@@ -354,133 +357,77 @@ def index():
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
:param file_filter: 格式如 "Excel Files (*.xlsx)"
"""
# 检测是否为原生模式 (PyWebview)
is_native = False
native_window = None
try:
# 使用 getattr 安全获取 app.native避免属性不存在错误
# 并在 reload=True 时 native 可能未能正确初始化
n_obj = getattr(app, "native", None)
if n_obj and getattr(n_obj, "main_window", None):
is_native = True
native_window = n_obj.main_window
except Exception as e:
print(f"DEBUG: Native check error: {e}")
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError
import platform
import subprocess
print(
f"DEBUG: save_file_with_dialog called. is_native={is_native}, filename={filename}"
if platform.system() == "Windows":
try:
# 构建 PowerShell 脚本
# 注意:过滤器格式为 "描述|*.ext|所有文件|*.*"
ps_filter = file_filter.replace("(", "|").replace(")", "")
if "|" not in ps_filter:
ps_filter += f"|{os.path.splitext(filename)[1] or '*.*'}"
# 简单清洗 filter 字符串以适应 PowerShell (e.g., "Excel Files *.xlsx" -> "Excel Files|*.xlsx")
# 这里做一个简化的映射,确保格式正确
if "Excel" in file_filter:
ps_filter = "Excel Files (*.xlsx)|*.xlsx|All Files (*.*)|*.*"
elif "DXF" in file_filter:
ps_filter = "DXF Files (*.dxf)|*.dxf|All Files (*.*)|*.*"
elif "ZIP" in file_filter:
ps_filter = "ZIP Archives (*.zip)|*.zip|All Files (*.*)|*.*"
else:
ps_filter = "All Files (*.*)|*.*"
ps_script = f"""
Add-Type -AssemblyName System.Windows.Forms
$d = New-Object System.Windows.Forms.SaveFileDialog
$d.Filter = "{ps_filter}"
$d.FileName = "{filename}"
$d.Title = "保存文件"
if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
Write-Output $d.FileName
}}
"""
# 运行 PowerShell
# 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁)
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
print("DEBUG: invoking PowerShell SaveFileDialog...")
# 使用 run.io_bound 在线程中执行,避免阻塞 UI
def run_ps():
result = subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
text=True,
startupinfo=startupinfo
)
if is_native and native_window:
try:
# PyWebview 的 create_file_dialog 的 file_types 参数期望一个字符串元组
# 格式如: ('Description (*.ext)', 'All files (*.*)')
file_types = (file_filter,)
print(f"DEBUG: calling create_file_dialog with types={file_types}")
# 在 Native 模式下create_file_dialog 是同步阻塞的
# 注意:必须使用 app.native.SAVE_DIALOG
save_path = native_window.create_file_dialog(
app.native.SAVE_DIALOG,
directory="",
save_filename=filename,
file_types=file_types,
)
print(f"DEBUG: save_path result: {save_path}")
# 用户取消
if not save_path:
return
# 处理返回类型 (PyWebview 可能返回字符串或列表)
if isinstance(save_path, (list, tuple)):
if not save_path:
return
save_path = save_path[0]
# 确保文件名后缀正确
if not save_path.lower().endswith(
os.path.splitext(filename)[1].lower()
):
save_path += os.path.splitext(filename)[1]
await callback(save_path)
ui.notify(f"文件已保存至: {save_path}", type="positive")
return # 成功处理,退出
except Exception as e:
import traceback
traceback.print_exc()
print(f"ERROR in save_file_with_dialog (native): {e}")
# ui.notify(f"原生保存失败,尝试其他方式: {e}", type="warning")
print(f"原生保存失败,尝试其他方式: {e}")
# 继续向下执行,尝试 fallback
# 非 Native 模式 (或 Native 失败),尝试使用 Tkinter (仅限本地环境)
try:
import tkinter as tk
from tkinter import filedialog
return result.stdout.strip()
from nicegui import run
print("DEBUG: Attempting Tkinter dialog...")
def get_save_path_tk(default_name, f_filter):
try:
# 创建隐藏的根窗口
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True) # 尝试置顶
# 转换 filter 格式: "Excel Files (*.xlsx)" -> [("Excel Files", "*.xlsx")]
filetypes = []
if "(" in f_filter and ")" in f_filter:
desc = f_filter.split("(")[0].strip()
ext = f_filter.split("(")[1].split(")")[0]
filetypes.append((desc, ext))
filetypes.append(("All files", "*.*"))
path = filedialog.asksaveasfilename(
initialfile=default_name, filetypes=filetypes, title="保存文件"
)
root.destroy()
return path
except Exception as ex:
print(f"Tkinter inner error: {ex}")
return None
# 在线程中运行 tkinter避免阻塞 asyncio 事件循环
save_path = await run.io_bound(get_save_path_tk, filename, file_filter)
save_path = await run.io_bound(run_ps)
if save_path:
print(f"DEBUG: Tkinter save_path: {save_path}")
# 确保文件名后缀正确
if not save_path.lower().endswith(
os.path.splitext(filename)[1].lower()
):
save_path += os.path.splitext(filename)[1]
print(f"DEBUG: PowerShell returned path: {save_path}")
await callback(save_path)
ui.notify(f"文件已保存至: {save_path}", type="positive")
return # 成功处理
elif save_path is None:
print("DEBUG: Tkinter dialog cancelled or failed silently.")
# 如果是用户取消(返回空字符串),通常不需要回退到下载。
# 但这里如果 Tkinter 彻底失败返回 None可能需要回退。
# askopenfilename 返回空字符串表示取消。我们假设 None 是异常。
# 这里简化处理:只要没拿到路径且没报错,就认为是取消。
if save_path == "":
return
else:
print("DEBUG: PowerShell dialog cancelled or empty result.")
# 用户取消,直接返回,不回退
return
except Exception as e:
print(f"Tkinter dialog failed: {e}")
# Fallback to ui.download if tkinter fails
print(f"PowerShell dialog failed: {e}")
# 出错则回退到 ui.download
# 最后的回退方案:浏览器下载
print("DEBUG: Falling back to ui.download")
# 统一回退方案:浏览器下载
print("DEBUG: Using ui.download fallback")
temp_path = os.path.join(state["temp_dir"], filename)
await callback(temp_path)
ui.download(temp_path)
@@ -1128,18 +1075,18 @@ if getattr(sys, "frozen", False):
)
else:
# 普通使用环境保留日志功能
ui.run(
title="海上风电场集电线路优化",
host="127.0.0.1",
reload=True,
port=target_port,
native=False,
)
# ui.run(
# title="海上风电场集电线路优化",
# host="127.0.0.1",
# port=target_port,
# reload=True,
# window_size=(1280, 800),
# native=True,
# port=target_port,
# native=False,
# )
ui.run(
title="海上风电场集电线路优化",
host="127.0.0.1",
port=target_port,
reload=True,
window_size=(1280, 800),
native=True,
)