fix: 优化文件保存对话框并启用原生窗口模式
- 添加 matplotlib.use('Agg') 设置非交互式后端
- 重构 save_file_with_dialog 函数,使用 PowerShell 原生对话框替代 Tkinter
- 解决 PyWebview/Tkinter 线程冲突导致的 PicklingError 问题
- 启用 native=True 原生窗口模式,提供更好的用户体验
This commit is contained in:
201
gui.py
201
gui.py
@@ -4,6 +4,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
import matplotlib.backends.backend_svg
|
import matplotlib.backends.backend_svg
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -354,133 +357,77 @@ def index():
|
|||||||
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
|
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
|
||||||
:param file_filter: 格式如 "Excel Files (*.xlsx)"
|
:param file_filter: 格式如 "Excel Files (*.xlsx)"
|
||||||
"""
|
"""
|
||||||
# 检测是否为原生模式 (PyWebview)
|
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
|
||||||
is_native = False
|
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError
|
||||||
native_window = None
|
import platform
|
||||||
try:
|
import subprocess
|
||||||
# 使用 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}")
|
|
||||||
|
|
||||||
print(
|
if platform.system() == "Windows":
|
||||||
f"DEBUG: save_file_with_dialog called. is_native={is_native}, filename={filename}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_native and native_window:
|
|
||||||
try:
|
try:
|
||||||
# PyWebview 的 create_file_dialog 的 file_types 参数期望一个字符串元组
|
# 构建 PowerShell 脚本
|
||||||
# 格式如: ('Description (*.ext)', 'All files (*.*)')
|
# 注意:过滤器格式为 "描述|*.ext|所有文件|*.*"
|
||||||
file_types = (file_filter,)
|
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 (*.*)|*.*"
|
||||||
|
|
||||||
print(f"DEBUG: calling create_file_dialog with types={file_types}")
|
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
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
# 在 Native 模式下,create_file_dialog 是同步阻塞的
|
# 运行 PowerShell
|
||||||
# 注意:必须使用 app.native.SAVE_DIALOG
|
# 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁)
|
||||||
save_path = native_window.create_file_dialog(
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
app.native.SAVE_DIALOG,
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
directory="",
|
|
||||||
save_filename=filename,
|
print("DEBUG: invoking PowerShell SaveFileDialog...")
|
||||||
file_types=file_types,
|
|
||||||
)
|
# 使用 run.io_bound 在线程中执行,避免阻塞 UI
|
||||||
|
def run_ps():
|
||||||
|
result = subprocess.run(
|
||||||
|
["powershell", "-Command", ps_script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
startupinfo=startupinfo
|
||||||
|
)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
print(f"DEBUG: save_path result: {save_path}")
|
from nicegui import run
|
||||||
|
save_path = await run.io_bound(run_ps)
|
||||||
|
|
||||||
# 用户取消
|
if save_path:
|
||||||
if not save_path:
|
print(f"DEBUG: PowerShell returned path: {save_path}")
|
||||||
|
await callback(save_path)
|
||||||
|
ui.notify(f"文件已保存至: {save_path}", type="positive")
|
||||||
return
|
return
|
||||||
|
else:
|
||||||
# 处理返回类型 (PyWebview 可能返回字符串或列表)
|
print("DEBUG: PowerShell dialog cancelled or empty result.")
|
||||||
if isinstance(save_path, (list, tuple)):
|
# 用户取消,直接返回,不回退
|
||||||
if not save_path:
|
return
|
||||||
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:
|
except Exception as e:
|
||||||
import traceback
|
print(f"PowerShell dialog failed: {e}")
|
||||||
|
# 出错则回退到 ui.download
|
||||||
|
|
||||||
traceback.print_exc()
|
# 统一回退方案:浏览器下载
|
||||||
print(f"ERROR in save_file_with_dialog (native): {e}")
|
print("DEBUG: Using ui.download fallback")
|
||||||
# ui.notify(f"原生保存失败,尝试其他方式: {e}", type="warning")
|
|
||||||
print(f"原生保存失败,尝试其他方式: {e}")
|
|
||||||
# 继续向下执行,尝试 fallback
|
|
||||||
|
|
||||||
# 非 Native 模式 (或 Native 失败),尝试使用 Tkinter (仅限本地环境)
|
|
||||||
try:
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import filedialog
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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]
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Tkinter dialog failed: {e}")
|
|
||||||
# Fallback to ui.download if tkinter fails
|
|
||||||
|
|
||||||
# 最后的回退方案:浏览器下载
|
|
||||||
print("DEBUG: Falling back to ui.download")
|
|
||||||
temp_path = os.path.join(state["temp_dir"], filename)
|
temp_path = os.path.join(state["temp_dir"], filename)
|
||||||
await callback(temp_path)
|
await callback(temp_path)
|
||||||
ui.download(temp_path)
|
ui.download(temp_path)
|
||||||
@@ -1128,18 +1075,18 @@ if getattr(sys, "frozen", False):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 普通使用环境保留日志功能
|
# 普通使用环境保留日志功能
|
||||||
ui.run(
|
|
||||||
title="海上风电场集电线路优化",
|
|
||||||
host="127.0.0.1",
|
|
||||||
reload=True,
|
|
||||||
port=target_port,
|
|
||||||
native=False,
|
|
||||||
)
|
|
||||||
# ui.run(
|
# ui.run(
|
||||||
# title="海上风电场集电线路优化",
|
# title="海上风电场集电线路优化",
|
||||||
# host="127.0.0.1",
|
# host="127.0.0.1",
|
||||||
# port=target_port,
|
|
||||||
# reload=True,
|
# reload=True,
|
||||||
# window_size=(1280, 800),
|
# port=target_port,
|
||||||
# native=True,
|
# native=False,
|
||||||
# )
|
# )
|
||||||
|
ui.run(
|
||||||
|
title="海上风电场集电线路优化",
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=target_port,
|
||||||
|
reload=True,
|
||||||
|
window_size=(1280, 800),
|
||||||
|
native=True,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user