feat: 优化文件保存对话框并增强系统稳定性
- 重新启用run.io_bound()调用,使用后台线程执行PowerShell脚本 - 为所有文件保存操作添加按钮防重复点击功能 - 新增win32_helper模块,提供Win32 API和COM接口的文件对话框 - 简化导出最佳方案DXF的代码结构 - 改进异步操作和错误处理机制
This commit is contained in:
75
gui.py
75
gui.py
@@ -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,9 +356,12 @@ def index():
|
||||
:param filename: 默认文件名
|
||||
:param callback: 接收文件路径并执行保存操作的函数 (filepath) -> None
|
||||
:param file_filter: 格式如 "Excel Files (*.xlsx)"
|
||||
:param sender: 触发该操作的 UI 组件,用于在操作期间禁用以防重复点击
|
||||
"""
|
||||
if sender:
|
||||
sender.disable()
|
||||
try:
|
||||
# 方案:使用 PowerShell 弹出原生保存对话框 (仅限 Windows)
|
||||
# 这完全避免了 Python UI 库 (Tkinter/PyWebview) 的线程冲突和 PicklingError
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
@@ -399,15 +402,16 @@ def index():
|
||||
|
||||
print("DEBUG: invoking PowerShell SaveFileDialog...")
|
||||
|
||||
# 在 native 模式下直接同步执行,不使用 run.io_bound()
|
||||
result = subprocess.run(
|
||||
# 使用 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)
|
||||
@@ -427,6 +431,9 @@ def index():
|
||||
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(
|
||||
|
||||
232
win32_helper.py
Normal file
232
win32_helper.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import os
|
||||
|
||||
def show_save_dialog_win32():
|
||||
"""
|
||||
使用 ctypes 直接调用 Windows API (GetSaveFileNameW)
|
||||
不需要子进程,可以在线程中运行 (run.io_bound)
|
||||
"""
|
||||
try:
|
||||
# 定义 OPENFILENAME 结构体
|
||||
class OPENFILENAME(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("lStructSize", ctypes.wintypes.DWORD),
|
||||
("hwndOwner", ctypes.wintypes.HWND),
|
||||
("hInstance", ctypes.wintypes.HINSTANCE),
|
||||
("lpstrFilter", ctypes.wintypes.LPCWSTR),
|
||||
("lpstrCustomFilter", ctypes.wintypes.LPWSTR),
|
||||
("nMaxCustFilter", ctypes.wintypes.DWORD),
|
||||
("nFilterIndex", ctypes.wintypes.DWORD),
|
||||
("lpstrFile", ctypes.wintypes.LPWSTR),
|
||||
("nMaxFile", ctypes.wintypes.DWORD),
|
||||
("lpstrFileTitle", ctypes.wintypes.LPWSTR),
|
||||
("nMaxFileTitle", ctypes.wintypes.DWORD),
|
||||
("lpstrInitialDir", ctypes.wintypes.LPCWSTR),
|
||||
("lpstrTitle", ctypes.wintypes.LPCWSTR),
|
||||
("Flags", ctypes.wintypes.DWORD),
|
||||
("nFileOffset", ctypes.wintypes.WORD),
|
||||
("nFileExtension", ctypes.wintypes.WORD),
|
||||
("lpstrDefExt", ctypes.wintypes.LPCWSTR),
|
||||
("lCustData", ctypes.wintypes.LPARAM),
|
||||
("lpfnHook", ctypes.wintypes.LPVOID),
|
||||
("lpTemplateName", ctypes.wintypes.LPCWSTR),
|
||||
# 还有更多字段,但这通常足够了
|
||||
# ("pvReserved", ctypes.wintypes.LPVOID),
|
||||
# ("dwReserved", ctypes.wintypes.DWORD),
|
||||
# ("FlagsEx", ctypes.wintypes.DWORD),
|
||||
]
|
||||
|
||||
# 准备缓冲区
|
||||
filename_buffer = ctypes.create_unicode_buffer(260) # MAX_PATH
|
||||
# 设置初始文件名
|
||||
filename_buffer.value = "win32_save.xlsx"
|
||||
|
||||
# 准备过滤器 (用 \0 分隔)
|
||||
# 格式: "描述\0模式\0描述\0模式\0\0"
|
||||
filter_str = "Excel Files (*.xlsx)\0*.xlsx\0All Files (*.*)\0*.*\0\0"
|
||||
|
||||
ofn = OPENFILENAME()
|
||||
ofn.lStructSize = ctypes.sizeof(OPENFILENAME)
|
||||
ofn.hwndOwner = 0 # NULL
|
||||
ofn.lpstrFilter = filter_str
|
||||
ofn.lpstrFile = ctypes.cast(filename_buffer, ctypes.wintypes.LPWSTR)
|
||||
ofn.nMaxFile = 260
|
||||
ofn.lpstrDefExt = "xlsx"
|
||||
ofn.lpstrTitle = "保存文件 (Win32 API)"
|
||||
# OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR
|
||||
ofn.Flags = 0x00000002 | 0x00000800 | 0x00000008
|
||||
|
||||
comdlg32 = ctypes.windll.comdlg32
|
||||
|
||||
# 调用 API
|
||||
# GetSaveFileNameW 返回非零值表示成功
|
||||
if comdlg32.GetSaveFileNameW(ctypes.byref(ofn)):
|
||||
return filename_buffer.value
|
||||
else:
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Win32 API Error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def show_save_dialog_com():
|
||||
"""
|
||||
使用 COM 接口 IFileSaveDialog (Windows Vista+)
|
||||
提供更现代化的文件保存对话框,支持更多功能
|
||||
"""
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
import uuid
|
||||
|
||||
# 定义必要的常量
|
||||
CLSCTX_INPROC_SERVER = 1
|
||||
S_OK = 0
|
||||
FOS_OVERWRITEPROMPT = 0x00000002
|
||||
FOS_PATHMUSTEXIST = 0x00000800
|
||||
FOS_NOCHANGEDIR = 0x00000008
|
||||
SIGDN_FILESYSPATH = 0x80058000
|
||||
|
||||
# IFileSaveDialog 的 CLSID 和 IID
|
||||
CLSID_FileSaveDialog = uuid.UUID("{C0B4E2F3-BA21-4773-8DBA-335EC946EB8B}")
|
||||
IID_IFileSaveDialog = uuid.UUID("{84bccd23-5fde-4cdb-aea4-af64b83d78ab}")
|
||||
IID_IShellItem = uuid.UUID("{43826d1e-e718-42ee-bc55-a1e261c37bfe}")
|
||||
|
||||
# 加载 ole32.dll
|
||||
ole32 = ctypes.windll.ole32
|
||||
|
||||
# CoInitialize
|
||||
ole32.CoInitialize(None)
|
||||
|
||||
# CoCreateInstance
|
||||
p_dialog = ctypes.c_void_p()
|
||||
hr = ole32.CoCreateInstance(
|
||||
ctypes.byref(CLSID_FileSaveDialog),
|
||||
None,
|
||||
CLSCTX_INPROC_SERVER,
|
||||
ctypes.byref(IID_IFileSaveDialog),
|
||||
ctypes.byref(p_dialog)
|
||||
)
|
||||
|
||||
if hr != S_OK:
|
||||
print(f"CoCreateInstance failed: {hr}")
|
||||
return None
|
||||
|
||||
# 定义 IFileSaveDialog 的 vtable 方法
|
||||
# 我们只需要调用 Show, GetResult, SetOptions, SetFileName, SetDefaultExtension, SetFileTypeIndex
|
||||
# 这些方法在 IFileOpenDialog 基类中定义
|
||||
|
||||
# SetOptions
|
||||
class IFileSaveDialogVtbl(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("QueryInterface", ctypes.c_void_p),
|
||||
("AddRef", ctypes.c_void_p),
|
||||
("Release", ctypes.c_void_p),
|
||||
# IModalWindow
|
||||
("Show", ctypes.c_void_p),
|
||||
# IFileDialog
|
||||
("SetFileTypes", ctypes.c_void_p),
|
||||
("SetFileTypeIndex", ctypes.c_void_p),
|
||||
("GetFileTypeIndex", ctypes.c_void_p),
|
||||
("Advise", ctypes.c_void_p),
|
||||
("Unadvise", ctypes.c_void_p),
|
||||
("SetOptions", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_ulong)),
|
||||
("GetOptions", ctypes.c_void_p),
|
||||
("SetDefaultFolder", ctypes.c_void_p),
|
||||
("SetFolder", ctypes.c_void_p),
|
||||
("GetFolder", ctypes.c_void_p),
|
||||
("GetCurrentSelection", ctypes.c_void_p),
|
||||
("SetFileName", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_wchar_p)),
|
||||
("GetFileName", ctypes.c_void_p),
|
||||
("SetTitle", ctypes.c_void_p),
|
||||
("SetOkButtonLabel", ctypes.c_void_p),
|
||||
("SetFileNameLabel", ctypes.c_void_p),
|
||||
("GetResult", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p))),
|
||||
("AddPlace", ctypes.c_void_p),
|
||||
("SetDefaultExtension", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_wchar_p)),
|
||||
("Close", ctypes.c_void_p),
|
||||
("SetClientGuid", ctypes.c_void_p),
|
||||
("ClearClientData", ctypes.c_void_p),
|
||||
("SetFilter", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
# 获取 vtable
|
||||
vtable = ctypes.cast(p_dialog, ctypes.POINTER(ctypes.POINTER(IFileSaveDialogVtbl))).contents.contents
|
||||
|
||||
# 调用 SetOptions
|
||||
hr = vtable.SetOptions(p_dialog, FOS_OVERWRITEPROMPT | FOS_PATHMUSTEXIST | FOS_NOCHANGEDIR)
|
||||
if hr != S_OK:
|
||||
print(f"SetOptions failed: {hr}")
|
||||
return None
|
||||
|
||||
# 调用 SetFileName
|
||||
hr = vtable.SetFileName(p_dialog, "com_save.xlsx")
|
||||
if hr != S_OK:
|
||||
print(f"SetFileName failed: {hr}")
|
||||
return None
|
||||
|
||||
# 调用 SetDefaultExtension
|
||||
hr = vtable.SetDefaultExtension(p_dialog, "xlsx")
|
||||
if hr != S_OK:
|
||||
print(f"SetDefaultExtension failed: {hr}")
|
||||
return None
|
||||
|
||||
# 调用 SetFileTypeIndex
|
||||
hr = vtable.SetFileTypeIndex(p_dialog, 1)
|
||||
if hr != S_OK:
|
||||
print(f"SetFileTypeIndex failed: {hr}")
|
||||
return None
|
||||
|
||||
# 调用 Show
|
||||
hr = vtable.Show(p_dialog, 0) # 0 表示没有父窗口
|
||||
if hr != S_OK:
|
||||
# 用户取消
|
||||
return None
|
||||
|
||||
# 调用 GetResult
|
||||
p_result = ctypes.c_void_p()
|
||||
hr = vtable.GetResult(p_dialog, ctypes.byref(p_result))
|
||||
if hr != S_OK:
|
||||
print(f"GetResult failed: {hr}")
|
||||
return None
|
||||
|
||||
# 定义 IShellItem 接口
|
||||
class IShellItemVtbl(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("QueryInterface", ctypes.c_void_p),
|
||||
("AddRef", ctypes.c_void_p),
|
||||
("Release", ctypes.c_void_p),
|
||||
("BindToHandler", ctypes.c_void_p),
|
||||
("GetParent", ctypes.c_void_p),
|
||||
("GetDisplayName", ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_ulong, ctypes.POINTER(ctypes.c_wchar_p))),
|
||||
("GetAttributes", ctypes.c_void_p),
|
||||
("Compare", ctypes.c_void_p),
|
||||
]
|
||||
|
||||
# 获取 IShellItem 的 vtable
|
||||
result_vtable = ctypes.cast(p_result, ctypes.POINTER(ctypes.POINTER(IShellItemVtbl))).contents.contents
|
||||
|
||||
# 调用 GetDisplayName
|
||||
p_display_name = ctypes.c_wchar_p()
|
||||
hr = result_vtable.GetDisplayName(p_result, SIGDN_FILESYSPATH, ctypes.byref(p_display_name))
|
||||
if hr != S_OK:
|
||||
print(f"GetDisplayName failed: {hr}")
|
||||
return None
|
||||
|
||||
filepath = p_display_name.value
|
||||
|
||||
# 清理
|
||||
ole32.CoUninitialize()
|
||||
|
||||
return filepath
|
||||
|
||||
except ImportError as e:
|
||||
print(f"COM Error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"COM Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
Reference in New Issue
Block a user