feat: 改进文件保存对话框,支持跨平台系统原生保存

主要改进:
1. 新增 save_file_with_dialog 函数
   - 优先使用 PyWebview 原生模式保存对话框
   - 回退到 Tkinter 对话框(本地环境)
   - 最终回退到浏览器下载方式

2. 优化所有导出功能
   - Excel 对比表导出支持系统保存对话框
   - DXF 文件导出支持系统保存对话框
   - ZIP 批量导出支持系统保存对话框
   - 模板导出支持系统保存对话框

3. 代码质量改进
   - 统一异步函数命名规范(on_click_*)
   - 改进代码格式化和缩进
   - 添加详细的调试日志

4. 用户体验提升
   - 用户可以自由选择保存位置
   - 支持文件类型过滤
   - 自动处理文件名后缀
This commit is contained in:
dmy
2026-01-05 21:32:46 +08:00
parent 751bdef245
commit 15d8f4881d

563
gui.py
View File

@@ -5,7 +5,7 @@ import contextlib
import tempfile import tempfile
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.backends.backend_svg import matplotlib.backends.backend_svg
from nicegui import ui, events from nicegui import ui, events, app
from main import ( from main import (
compare_design_methods, compare_design_methods,
export_to_dxf, export_to_dxf,
@@ -54,13 +54,15 @@ if not os.path.exists(state["temp_dir"]):
@ui.page("/") @ui.page("/")
def index(): def index():
# 注入 CSS 隐藏表格自带的复选框列,并定义选中行的高亮背景色 # 注入 CSS 隐藏表格自带的复选框列,并定义选中行的高亮背景色
ui.add_head_html(""" ui.add_head_html(
"""
<style> <style>
.hide-selection-column .q-table__selection { display: none; } .hide-selection-column .q-table__selection { display: none; }
.hide-selection-column .q-table tr.selected { background-color: #e3f2fd !important; } .hide-selection-column .q-table tr.selected { background-color: #e3f2fd !important; }
.no-list .q-uploader__list { display: none !important; } .no-list .q-uploader__list { display: none !important; }
</style> </style>
""") """
)
ui.query("body").style( ui.query("body").style(
'background-color: #f0f2f5; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;' 'background-color: #f0f2f5; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;'
) )
@@ -71,12 +73,12 @@ def index():
"results_table": None, "results_table": None,
"plot_container": None, "plot_container": None,
"export_row": None, "export_row": None,
"export_selected_btn": None, # 新增按钮引用 "export_selected_btn": None, # 新增按钮引用
"status_label": None, "status_label": None,
"upload_widget": None, "upload_widget": None,
"run_btn": None, "run_btn": None,
"current_file_container": None, # 替换 label 为 container "current_file_container": None, # 替换 label 为 container
"info_container": None, # 新增信息展示容器 "info_container": None, # 新增信息展示容器
} }
def update_info_panel(): def update_info_panel():
@@ -85,64 +87,96 @@ def index():
with refs["info_container"]: with refs["info_container"]:
# System Params - Always show # System Params - Always show
with ui.row().classes("w-full items-center gap-4 mb-2"): with ui.row().classes("w-full items-center gap-4 mb-2"):
ui.icon('settings', color='primary').classes('text-2xl') ui.icon("settings", color="primary").classes("text-2xl")
ui.label("系统参数").classes("text-lg font-bold") ui.label("系统参数").classes("text-lg font-bold")
params_text = [] params_text = []
# 获取电压 # 获取电压
v = 66000 # Default v = 66000 # Default
is_default_v = True is_default_v = True
if state.get("system_params") and 'voltage' in state['system_params']: if (
v = state['system_params']['voltage'] state.get("system_params")
and "voltage" in state["system_params"]
):
v = state["system_params"]["voltage"]
is_default_v = False is_default_v = False
v_str = f"电压: {v/1000:.1f} kV" if v >= 1000 else f"电压: {v} V" v_str = f"电压: {v/1000:.1f} kV" if v >= 1000 else f"电压: {v} V"
if is_default_v: if is_default_v:
v_str += " (默认)" v_str += " (默认)"
params_text.append(v_str) params_text.append(v_str)
# 获取功率因数 # 获取功率因数
pf = 0.95 # Default pf = 0.95 # Default
is_default_pf = True is_default_pf = True
if state.get("system_params") and 'power_factor' in state['system_params']: if (
pf = state['system_params']['power_factor'] state.get("system_params")
and "power_factor" in state["system_params"]
):
pf = state["system_params"]["power_factor"]
is_default_pf = False is_default_pf = False
pf_str = f"功率因数: {pf}" pf_str = f"功率因数: {pf}"
if is_default_pf: if is_default_pf:
pf_str += " (默认)" pf_str += " (默认)"
params_text.append(pf_str) params_text.append(pf_str)
for p in params_text:
ui.chip(p, icon='bolt').props('outline color=primary')
ui.separator().classes('my-2') for p in params_text:
ui.chip(p, icon="bolt").props("outline color=primary")
ui.separator().classes("my-2")
# Cables # Cables
if state.get("cable_specs"): if state.get("cable_specs"):
with ui.row().classes("w-full items-center gap-2 mb-2"): with ui.row().classes("w-full items-center gap-2 mb-2"):
ui.icon('cable', color='secondary').classes('text-2xl') ui.icon("cable", color="secondary").classes("text-2xl")
ui.label("电缆规格参数").classes("text-lg font-bold") ui.label("电缆规格参数").classes("text-lg font-bold")
columns = [ columns = [
{'name': 'section', 'label': '截面 (mm²)', 'field': 'section', 'align': 'center'}, {
{'name': 'capacity', 'label': '载流量 (A)', 'field': 'capacity', 'align': 'center'}, "name": "section",
{'name': 'resistance', 'label': '电阻 (Ω/km)', 'field': 'resistance', 'align': 'center'}, "label": "截面 (mm²)",
{'name': 'cost', 'label': '参考单价 (元/m)', 'field': 'cost', 'align': 'center'}, "field": "section",
"align": "center",
},
{
"name": "capacity",
"label": "载流量 (A)",
"field": "capacity",
"align": "center",
},
{
"name": "resistance",
"label": "电阻 (Ω/km)",
"field": "resistance",
"align": "center",
},
{
"name": "cost",
"label": "参考单价 (元/m)",
"field": "cost",
"align": "center",
},
] ]
rows = [] rows = []
for spec in state["cable_specs"]: for spec in state["cable_specs"]:
# spec is (section, capacity, resistance, cost, is_optional) # spec is (section, capacity, resistance, cost, is_optional)
rows.append({ rows.append(
'section': spec[0], {
'capacity': spec[1], "section": spec[0],
'resistance': spec[2], "capacity": spec[1],
'cost': spec[3] "resistance": spec[2],
}) "cost": spec[3],
ui.table(columns=columns, rows=rows).classes('w-full').props('dense flat bordered') }
)
ui.table(columns=columns, rows=rows).classes("w-full").props(
"dense flat bordered"
)
else: else:
ui.label("未检测到电缆数据,将使用默认参数。").classes("text-gray-500 italic") ui.label("未检测到电缆数据,将使用默认参数。").classes(
"text-gray-500 italic"
)
async def handle_upload(e: events.UploadEventArguments): async def handle_upload(e: events.UploadEventArguments):
try: try:
@@ -193,27 +227,38 @@ def index():
state["excel_path"] = path state["excel_path"] = path
state["original_filename"] = filename state["original_filename"] = filename
ui.notify(f"文件已上传: {filename}", type="positive") ui.notify(f"文件已上传: {filename}", type="positive")
# 更新文件显示区域 # 更新文件显示区域
if refs["current_file_container"]: if refs["current_file_container"]:
refs["current_file_container"].clear() refs["current_file_container"].clear()
with refs["current_file_container"]: with refs["current_file_container"]:
with ui.row().classes('items-center w-full bg-blue-50 p-2 rounded border border-blue-200'): with ui.row().classes(
ui.icon('description', color='primary').classes('text-xl mr-2') "items-center w-full bg-blue-50 p-2 rounded border border-blue-200"
ui.label(filename).classes('font-medium text-gray-700 flex-grow') ):
ui.icon('check_circle', color='positive') ui.icon("description", color="primary").classes("text-xl mr-2")
ui.label(filename).classes(
"font-medium text-gray-700 flex-grow"
)
ui.icon("check_circle", color="positive")
# 加载数据 # 加载数据
try: try:
# 尝试解包 4 个返回值 (新版 main.py) # 尝试解包 4 个返回值 (新版 main.py)
state["turbines"], state["substation"], state["cable_specs"], state["system_params"] = load_data_from_excel(path) (
state["turbines"],
state["substation"],
state["cable_specs"],
state["system_params"],
) = load_data_from_excel(path)
except ValueError: except ValueError:
# 兼容旧版 (如果是 3 个返回值) # 兼容旧版 (如果是 3 个返回值)
state["turbines"], state["substation"], state["cable_specs"] = load_data_from_excel(path) state["turbines"], state["substation"], state["cable_specs"] = (
load_data_from_excel(path)
)
state["system_params"] = {} state["system_params"] = {}
update_info_panel() update_info_panel()
# 清空上传组件列表,以便下次选择(配合 .no-list CSS 使用) # 清空上传组件列表,以便下次选择(配合 .no-list CSS 使用)
if refs["upload_widget"]: if refs["upload_widget"]:
refs["upload_widget"].reset() refs["upload_widget"].reset()
@@ -221,6 +266,146 @@ def index():
except Exception as ex: except Exception as ex:
ui.notify(f"上传处理失败: {ex}", type="negative") ui.notify(f"上传处理失败: {ex}", type="negative")
async def save_file_with_dialog(filename, callback, file_filter="All files (*.*)"):
"""
跨平台文件保存助手。
如果是原生模式,弹出系统保存对话框。
如果是浏览器模式(但在本地运行),尝试使用 tkinter 弹出对话框。
最后回退到使用 nicegui ui.download。
:param filename: 默认文件名
: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}")
print(
f"DEBUG: save_file_with_dialog called. is_native={is_native}, filename={filename}"
)
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
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)
await callback(temp_path)
ui.download(temp_path)
def update_export_buttons(): def update_export_buttons():
if refs["export_row"]: if refs["export_row"]:
refs["export_row"].clear() refs["export_row"].clear()
@@ -229,17 +414,20 @@ def index():
# 获取文件名基础前缀 # 获取文件名基础前缀
file_prefix = "wind_farm" file_prefix = "wind_farm"
download_name = "wind_farm_design_result.xlsx" default_excel_name = "wind_farm_design_result.xlsx"
# 确定主对比 Excel 的生成路径 (对应 main.py 中的生成逻辑) # 确定主对比 Excel 的生成路径 (对应 main.py 中的生成逻辑)
if state.get("excel_path"): if state.get("excel_path"):
file_prefix = os.path.splitext(state["original_filename"])[0] file_prefix = os.path.splitext(state["original_filename"])[0]
download_name = f"{file_prefix}_result.xlsx" default_excel_name = f"{file_prefix}_result.xlsx"
main_excel_path = os.path.join(state["temp_dir"], f"{file_prefix}_design.xlsx") # 这里的路径是 main.py 中生成的源文件路径,用于复制
source_excel_path = os.path.join(
state["temp_dir"], f"{file_prefix}_design.xlsx"
)
else: else:
main_excel_path = "wind_farm_design.xlsx" source_excel_path = "wind_farm_design.xlsx"
# 寻找推荐方案:优先 Scenario 1 的最低成本,否则取全局最低成本 # 寻找推荐方案
scenario1_results = [r for r in state["results"] if "Scenario 1" in r["name"]] scenario1_results = [r for r in state["results"] if "Scenario 1" in r["name"]]
if scenario1_results: if scenario1_results:
best_res = min(scenario1_results, key=lambda x: x["cost"]) best_res = min(scenario1_results, key=lambda x: x["cost"])
@@ -247,14 +435,31 @@ def index():
best_res = min(state["results"], key=lambda x: x["cost"]) best_res = min(state["results"], key=lambda x: x["cost"])
with refs["export_row"]: with refs["export_row"]:
# --- 下载 Excel ---
async def save_excel(path):
import shutil
# 如果源文件存在,则复制到目标路径
if os.path.exists(source_excel_path):
shutil.copy2(source_excel_path, path)
else:
# 如果不存在,重新生成
export_all_scenarios_to_excel(state["results"], path)
async def on_click_excel():
await save_file_with_dialog(
default_excel_name, save_excel, "Excel Files (*.xlsx)"
)
ui.button( ui.button(
"下载 Excel 对比表", "下载 Excel 对比表",
on_click=lambda: ui.download(main_excel_path, download_name), on_click=on_click_excel,
).props("icon=download") ).props("icon=download")
# --- 导出推荐方案 DXF ---
def export_best_dxf(): def export_best_dxf():
if state["substation"] is not None: if state["substation"] is not None:
# 生成安全的文件名
safe_name = "".join( safe_name = "".join(
[ [
c c
@@ -262,31 +467,68 @@ def index():
if c.isalnum() or c in (" ", "-", "_") if c.isalnum() or c in (" ", "-", "_")
] ]
).strip() ).strip()
dxf_name = os.path.join(state["temp_dir"], f"{file_prefix}_best_{safe_name}.dxf") default_name = f"{file_prefix}_best_{safe_name}.dxf"
export_to_dxf( async def save_dxf(path):
best_res["turbines"], export_to_dxf(
state["substation"], best_res["turbines"],
best_res["eval"]["details"], state["substation"],
dxf_name, 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():
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,
)
await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)"
) )
ui.download(dxf_name)
ui.notify(f'已导出推荐方案: {best_res["name"]}', type="positive")
else: else:
ui.notify("缺少升压站数据,无法导出 DXF", type="negative") ui.notify("缺少升压站数据,无法导出 DXF", type="negative")
ui.button( ui.button(
f'导出推荐方案 DXF ({best_res["name"]})', on_click=export_best_dxf f'导出推荐方案 DXF ({best_res["name"]})', on_click=on_click_best_dxf
).props("icon=architecture color=accent") ).props("icon=architecture color=accent")
def export_selected_dxf(): # --- 导出选中方案 DXF ---
async def on_click_selected_dxf():
if not refs["results_table"] or not refs["results_table"].selected: if not refs["results_table"] or not refs["results_table"].selected:
ui.notify("请先在上方表格中选择一个方案", type="warning") ui.notify("请先在上方表格中选择一个方案", type="warning")
return return
selected_row = refs["results_table"].selected[0] selected_row = refs["results_table"].selected[0]
row_name = selected_row.get("original_name", selected_row.get("name")) row_name = selected_row.get("original_name", selected_row.get("name"))
selected_res = next( selected_res = next(
(r for r in state["results"] if r["name"] == row_name), None (r for r in state["results"] if r["name"] == row_name), None
) )
@@ -299,62 +541,55 @@ def index():
if c.isalnum() or c in (" ", "-", "_") if c.isalnum() or c in (" ", "-", "_")
] ]
).strip() ).strip()
dxf_name = os.path.join(state["temp_dir"], f"{file_prefix}_{safe_name}.dxf") default_name = f"{file_prefix}_{safe_name}.dxf"
export_to_dxf( async def save_dxf(path):
selected_res["turbines"], export_to_dxf(
state["substation"], selected_res["turbines"],
selected_res["eval"]["details"], state["substation"],
dxf_name, selected_res["eval"]["details"],
path,
)
await save_file_with_dialog(
default_name, save_dxf, "DXF Files (*.dxf)"
) )
ui.download(dxf_name)
ui.notify(f'已导出选中方案: {selected_res["name"]}', type="positive")
else: else:
ui.notify("无法导出:未找到方案数据或缺少升压站信息", type="negative") ui.notify(
"无法导出:未找到方案数据或缺少升压站信息", type="negative"
)
# 记录此按钮引用,以便 handle_row_click 更新文字
refs["export_selected_btn"] = ui.button( refs["export_selected_btn"] = ui.button(
"导出选中方案 DXF", on_click=export_selected_dxf "导出选中方案 DXF", on_click=on_click_selected_dxf
).props("icon=architecture color=primary") ).props("icon=architecture color=primary")
# 初始化按钮文字
clean_name = best_res["name"].replace("(推荐) ", "") clean_name = best_res["name"].replace("(推荐) ", "")
refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})") refs["export_selected_btn"].set_text(f"导出选中方案 ({clean_name})")
def export_all_dxf(): # --- 导出全部 ZIP ---
async def on_click_all_dxf():
if not state["results"] or state["substation"] is None: if not state["results"] or state["substation"] is None:
ui.notify("无方案数据可导出", type="warning") ui.notify("无方案数据可导出", type="warning")
return return
import zipfile default_name = f"{file_prefix}_all_results.zip"
# 1. 确定文件名 async def save_zip(path):
zip_filename = os.path.join(state["temp_dir"], f"{file_prefix}_all_results.zip") import zipfile
excel_result_name = f"{file_prefix}_summary.xlsx"
# 推断 main.py 生成的原始 Excel 路径 excel_result_name = f"{file_prefix}_summary.xlsx"
generated_excel_path = main_excel_path
try: with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile( # 1. Excel
zip_filename, "w", zipfile.ZIP_DEFLATED temp_excel = os.path.join(state["temp_dir"], "temp_export.xlsx")
) as zipf: export_all_scenarios_to_excel(state["results"], temp_excel)
# 2. 添加 Excel 结果表 zipf.write(temp_excel, arcname=excel_result_name)
if os.path.exists(generated_excel_path): try:
zipf.write(generated_excel_path, arcname=excel_result_name) os.remove(temp_excel)
else: except:
# 尝试重新生成 pass
try:
temp_excel = os.path.join(state["temp_dir"], "temp_export.xlsx")
export_all_scenarios_to_excel(
state["results"], temp_excel
)
zipf.write(temp_excel, arcname=excel_result_name)
os.remove(temp_excel)
except Exception as e:
print(f"生成Excel失败: {e}")
# 3. 添加所有 DXF # 2. DXFs
for res in state["results"]: for res in state["results"]:
safe_name = "".join( safe_name = "".join(
[ [
@@ -363,25 +598,24 @@ def index():
if c.isalnum() or c in (" ", "-", "_") if c.isalnum() or c in (" ", "-", "_")
] ]
).strip() ).strip()
dxf_name = os.path.join(state["temp_dir"], f"{file_prefix}_{safe_name}.dxf") dxf_name = os.path.join(
state["temp_dir"], f"{file_prefix}_{safe_name}.dxf"
)
export_to_dxf( export_to_dxf(
res["turbines"], res["turbines"],
state["substation"], state["substation"],
res["eval"]["details"], res["eval"]["details"],
dxf_name, dxf_name,
) )
zipf.write(dxf_name) zipf.write(dxf_name, arcname=os.path.basename(dxf_name))
try: try:
os.remove(dxf_name) os.remove(dxf_name)
except: except:
pass pass
ui.download(zip_filename)
ui.notify("已导出所有方案 (含Excel)", type="positive")
except Exception as ex:
ui.notify(f"导出失败: {ex}", type="negative")
await save_file_with_dialog(default_name, save_zip, "ZIP Files (*.zip)")
ui.button("导出全部方案 DXF (ZIP)", on_click=export_all_dxf).props( ui.button("导出全部方案 DXF (ZIP)", on_click=on_click_all_dxf).props(
"icon=folder_zip color=secondary" "icon=folder_zip color=secondary"
) )
@@ -507,11 +741,11 @@ def index():
# 默认推荐 Scenario 1 中成本最低的方案 # 默认推荐 Scenario 1 中成本最低的方案
scenario1_results = [r for r in results if "Scenario 1" in r["name"]] scenario1_results = [r for r in results if "Scenario 1" in r["name"]]
if scenario1_results: if scenario1_results:
best_res = min(scenario1_results, key=lambda x: x["cost"]) best_res = min(scenario1_results, key=lambda x: x["cost"])
else: else:
# 如果没有 Scenario 1则回退到全局最优 # 如果没有 Scenario 1则回退到全局最优
best_res = min(results, key=lambda x: x["cost"]) best_res = min(results, key=lambda x: x["cost"])
update_plot(best_res) update_plot(best_res)
ui.notify( ui.notify(
f'计算完成!已自动加载推荐方案: {best_res["name"]}', type="positive" f'计算完成!已自动加载推荐方案: {best_res["name"]}', type="positive"
@@ -595,48 +829,71 @@ def index():
) )
processing_dialog.props("persistent") processing_dialog.props("persistent")
with ui.header().classes("bg-primary text-white p-4 shadow-lg items-center no-wrap"): with ui.header().classes(
"bg-primary text-white p-4 shadow-lg items-center no-wrap"
):
with ui.column().classes("gap-0"): with ui.column().classes("gap-0"):
ui.label("海上风电场集电线路设计优化系统 v1.0").classes("text-2xl font-bold") ui.label("海上风电场集电线路设计优化系统 v1.0").classes(
"text-2xl font-bold"
)
with ui.column().classes("gap-0"): with ui.column().classes("gap-0"):
ui.label("Wind Farm Collector System Design Optimizer").classes( ui.label("Wind Farm Collector System Design Optimizer").classes(
"text-sm opacity-80" "text-sm opacity-80"
) )
ui.space() ui.space()
ui.label("中能建西北院海上能源业务开发部").classes( ui.label("中能建西北院海上能源业务开发部").classes("text-xl font-bold")
"text-xl font-bold"
)
with ui.row().classes("w-full p-4 gap-4"): with ui.row().classes("w-full p-4 gap-4"):
with ui.card().classes("w-1/4 p-4 shadow-md"): with ui.card().classes("w-1/4 p-4 shadow-md"):
ui.label("配置面板").classes("text-xl font-semibold mb-4 border-b pb-2") ui.label("配置面板").classes("text-xl font-semibold mb-4 border-b pb-2")
def export_template(): async def export_template():
from generate_template import create_template from generate_template import create_template
try: import shutil
async def save_template(path):
# 生成模板到当前目录
create_template() create_template()
ui.download("coordinates.xlsx") source = "coordinates.xlsx"
ui.notify("Excel 模板导出成功", type="positive") if os.path.exists(source):
except Exception as ex: shutil.copy2(source, path)
ui.notify(f"模板导出失败: {ex}", type="negative") else:
raise FileNotFoundError("无法生成模板文件")
await save_file_with_dialog(
"coordinates.xlsx",
save_template,
"Excel Files (*.xlsx)"
)
ui.button("导出 Excel 模板", on_click=export_template).classes( ui.button("导出 Excel 模板", on_click=export_template).classes(
"w-full mb-4" "w-full mb-4"
).props("icon=file_download outline color=primary") ).props("icon=file_download outline color=primary")
async def test_save_dialog():
async def dummy_callback(path):
# 仅作为测试,实际不写入文件,只弹出通知
ui.notify(f"测试成功!选定路径: {path}", type="info")
await save_file_with_dialog(
"test_save_dialog.txt", dummy_callback, "Text Files (*.txt)"
)
ui.button("测试对话框", on_click=test_save_dialog).classes(
"w-full mb-4"
).props("icon=bug_report outline color=orange")
ui.label("1. 上传坐标文件 (.xlsx)").classes("font-medium") ui.label("1. 上传坐标文件 (.xlsx)").classes("font-medium")
# 使用 .no-list CSS 隐藏 Quasar 默认列表,完全自定义文件显示 # 使用 .no-list CSS 隐藏 Quasar 默认列表,完全自定义文件显示
refs["upload_widget"] = ui.upload( refs["upload_widget"] = ui.upload(
label="选择Excel文件", label="选择Excel文件", on_upload=handle_upload, auto_upload=True
on_upload=handle_upload,
auto_upload=True
).classes("w-full mb-2 no-list") ).classes("w-full mb-2 no-list")
# 自定义文件显示容器 # 自定义文件显示容器
refs['current_file_container'] = ui.column().classes('w-full mb-4') refs["current_file_container"] = ui.column().classes("w-full mb-4")
with refs['current_file_container']: with refs["current_file_container"]:
ui.label('未选择文件').classes('text-xs text-gray-500 italic ml-1') ui.label("未选择文件").classes("text-xs text-gray-500 italic ml-1")
refs["run_btn"] = ( refs["run_btn"] = (
ui.button( ui.button(
@@ -649,9 +906,15 @@ def index():
with ui.column().classes("w-3/4 gap-4"): with ui.column().classes("w-3/4 gap-4"):
# 新增:信息展示卡片 # 新增:信息展示卡片
with ui.card().classes("w-full p-4 shadow-md").style("max-height: 400px; overflow-y: auto;"): with (
refs["info_container"] = ui.column().classes("w-full") ui.card()
ui.label("请上传 Excel 文件以查看系统参数和电缆规格...").classes("text-gray-500 italic") .classes("w-full p-4 shadow-md")
.style("max-height: 400px; overflow-y: auto;")
):
refs["info_container"] = ui.column().classes("w-full")
ui.label("请上传 Excel 文件以查看系统参数和电缆规格...").classes(
"text-gray-500 italic"
)
with ui.card().classes("w-full p-4 shadow-md"): with ui.card().classes("w-full p-4 shadow-md"):
ui.label("方案对比结果 (点击行查看拓扑详情)").classes( ui.label("方案对比结果 (点击行查看拓扑详情)").classes(
@@ -721,13 +984,16 @@ target_port = find_available_port(8082) # 从 8082 开始,避开常用的 808
# 检测是否为打包后的exe程序 # 检测是否为打包后的exe程序
import sys import sys
if getattr(sys, 'frozen', False):
if getattr(sys, "frozen", False):
# 修复无控制台模式下 stdout/stderr 为 None 导致的 logging 错误 # 修复无控制台模式下 stdout/stderr 为 None 导致的 logging 错误
class NullWriter: class NullWriter:
def write(self, text): def write(self, text):
pass pass
def flush(self): def flush(self):
pass pass
def isatty(self): def isatty(self):
return False return False
@@ -738,12 +1004,29 @@ if getattr(sys, 'frozen', False):
# 打包环境下禁用日志配置,避免在无控制台模式下出现错误 # 打包环境下禁用日志配置,避免在无控制台模式下出现错误
import logging.config import logging.config
logging.config.dictConfig({
'version': 1, logging.config.dictConfig(
'disable_existing_loggers': True, {
}) "version": 1,
ui.run(title="海上风电场集电线路优化", host='127.0.0.1', port=target_port, reload=False, window_size=(1280, 800),native=True) "disable_existing_loggers": True,
}
)
ui.run(
title="海上风电场集电线路优化",
host="127.0.0.1",
port=target_port,
reload=False,
window_size=(1280, 800),
native=True,
)
else: else:
# 普通使用环境保留日志功能 # 普通使用环境保留日志功能
# ui.run(title="海上风电场集电线路优化", host='127.0.0.1', reload=True, port=target_port, native=False) # 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) ui.run(
title="海上风电场集电线路优化",
host="127.0.0.1",
port=target_port,
reload=False,
window_size=(1280, 800),
native=True,
)