feat: 优化文件保存对话框并增强系统稳定性

- 重新启用run.io_bound()调用,使用后台线程执行PowerShell脚本
- 为所有文件保存操作添加按钮防重复点击功能
- 新增win32_helper模块,提供Win32 API和COM接口的文件对话框
- 简化导出最佳方案DXF的代码结构
- 改进异步操作和错误处理机制
This commit is contained in:
dmy
2026-01-07 12:47:58 +08:00
parent e0b5b0c3dc
commit 87cea6ed86
2 changed files with 316 additions and 113 deletions

197
gui.py
View File

@@ -10,7 +10,7 @@ matplotlib.use("Agg")
import matplotlib.backends.backend_svg
import matplotlib.pyplot as plt
import pandas as pd
from nicegui import app, events, ui
from nicegui import app, events, ui, run
from main import (
compare_design_methods,
@@ -347,7 +347,7 @@ def index():
except Exception as ex:
ui.notify(f"上传处理失败: {ex}", type="negative")
async def save_file_with_dialog(filename, callback, file_filter="All files (*.*)"):
async def save_file_with_dialog(filename, callback, file_filter="All files (*.*)", sender=None):
"""
跨平台文件保存助手。
如果是原生模式,弹出系统保存对话框。
@@ -356,77 +356,84 @@ def index():
:param filename: 默认文件名
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
:param file_filter: 格式如 "Excel Files (*.xlsx)"
:param sender: 触发该操作的 UI 组件,用于在操作期间禁用以防重复点击
"""
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError
import platform
import subprocess
if sender:
sender.disable()
try:
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
import platform
import subprocess
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 (*.*)|*.*"
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
}}
"""
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...")
# 在 native 模式下直接同步执行,不使用 run.io_bound()
result = subprocess.run(
["powershell", "-Command", ps_script],
capture_output=True,
text=True,
startupinfo=startupinfo
)
save_path = result.stdout.strip()
# 运行 PowerShell
# 使用 startupinfo 隐藏控制台窗口 (防止黑框闪烁)
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
print("DEBUG: invoking PowerShell SaveFileDialog...")
# 使用 run.io_bound 在后台线程执行,避免阻塞主事件循环
# 这样按钮的禁用状态可以立即同步到前端
result = await run.io_bound(
subprocess.run,
["powershell", "-Command", ps_script],
capture_output=True,
text=True,
startupinfo=startupinfo
)
save_path = result.stdout.strip()
if save_path:
print(f"DEBUG: PowerShell returned path: {save_path}")
await callback(save_path)
ui.notify(f"文件已保存至: {save_path}", type="positive")
return
else:
print("DEBUG: PowerShell dialog cancelled or empty result.")
# 用户取消,直接返回,不回退
return
if save_path:
print(f"DEBUG: PowerShell returned path: {save_path}")
await callback(save_path)
ui.notify(f"文件已保存至: {save_path}", type="positive")
return
else:
print("DEBUG: PowerShell dialog cancelled or empty result.")
# 用户取消,直接返回,不回退
return
except Exception as e:
print(f"PowerShell dialog failed: {e}")
# 出错则回退到 ui.download
except Exception as e:
print(f"PowerShell dialog failed: {e}")
# 出错则回退到 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)
# 统一回退方案:浏览器下载
print("DEBUG: Using ui.download fallback")
temp_path = os.path.join(state["temp_dir"], filename)
await callback(temp_path)
ui.download(temp_path)
finally:
if sender:
sender.enable()
def update_export_buttons():
if refs["export_row"]:
@@ -468,9 +475,9 @@ def index():
# 如果不存在,重新生成
export_all_scenarios_to_excel(state["results"], path)
async def on_click_excel():
async def on_click_excel(e):
await save_file_with_dialog(
default_excel_name, save_excel, "Excel Files (*.xlsx)"
default_excel_name, save_excel, "Excel Files (*.xlsx)", sender=e.sender
)
ui.button(
@@ -479,41 +486,7 @@ def index():
).props("icon=download")
# --- 导出推荐方案 DXF ---
def export_best_dxf():
if state["substation"] is not None:
safe_name = "".join(
[
c
for c in best_res["name"]
if c.isalnum() or c in (" ", "-", "_")
]
).strip()
default_name = f"{file_prefix}_best_{safe_name}.dxf"
async def save_dxf(path):
export_to_dxf(
best_res["turbines"],
state["substation"],
best_res["eval"]["details"],
path,
)
# 包装为 async 任务,并在 NiceGUI 事件循环中执行
async def run_save():
await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)"
)
# 这里的 export_best_dxf 本身是普通函数,绑定到 on_click
# 但我们需要它执行异步操作。最简单的是让 export_best_dxf 变为 async
# 或者在这里直接调用 run_save (但这在普通函数里不行)
# 更好的方法是将 export_best_dxf 定义为 async如下所示
return run_save()
else:
ui.notify("缺少升压站数据,无法导出 DXF", type="negative")
# 将 export_best_dxf 改为 async 并重命名,以便直接用作回调
async def on_click_best_dxf():
async def on_click_best_dxf(e):
if state["substation"] is not None:
safe_name = "".join(
[
@@ -533,7 +506,7 @@ def index():
)
await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)"
default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender
)
else:
ui.notify("缺少升压站数据,无法导出 DXF", type="negative")
@@ -543,7 +516,7 @@ def index():
).props("icon=architecture color=accent")
# --- 导出选中方案 DXF ---
async def on_click_selected_dxf():
async def on_click_selected_dxf(e):
if not refs["results_table"] or not refs["results_table"].selected:
ui.notify("请先在上方表格中选择一个方案", type="warning")
return
@@ -573,7 +546,7 @@ def index():
)
await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)"
default_name, save_dxf, "DXF Files (*.dxf)", sender=e.sender
)
else:
ui.notify(
@@ -588,7 +561,7 @@ def index():
refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})")
# --- 导出全部 ZIP ---
async def on_click_all_dxf():
async def on_click_all_dxf(e):
if not state["results"] or state["substation"] is None:
ui.notify("无方案数据可导出", type="warning")
return
@@ -634,7 +607,7 @@ def index():
except:
pass
await save_file_with_dialog(default_name, save_zip, "ZIP Files (*.zip)")
await save_file_with_dialog(default_name, save_zip, "ZIP Files (*.zip)", sender=e.sender)
ui.button("导出全部方案 DXF (ZIP)", on_click=on_click_all_dxf).props(
"icon=folder_zip color=secondary"
@@ -688,8 +661,6 @@ def index():
import queue
from nicegui import run
async def run_analysis():
if not state["excel_path"]:
ui.notify("请先上传 Excel 坐标文件!", type="warning")
@@ -879,7 +850,7 @@ def index():
# 使用 items-stretch 确保所有子元素高度一致
with ui.row().classes("w-full items-stretch gap-4"):
# 1. 导出模板按钮
async def export_template():
async def export_template(e):
import shutil
from generate_template import create_template
@@ -900,7 +871,7 @@ def index():
raise FileNotFoundError("无法生成模板文件")
await save_file_with_dialog(
"coordinates.xlsx", save_template, "Excel Files (*.xlsx)"
"coordinates.xlsx", save_template, "Excel Files (*.xlsx)", sender=e.sender
)
ui.button("导出 Excel 模板", on_click=export_template).classes(