From 51ca203ad35f8aded9a398c9a0220cb7724b80ac Mon Sep 17 00:00:00 2001 From: suyu <1643689728@qq.com> Date: Wed, 26 Aug 2026 08:08:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E7=A8=8B=E5=BA=8F=E5=AF=BC=E5=87=BA=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/integrated_platform.pro | 5 + app/src/main.cpp | 76 +++- app/src/services/logic_command_service.cpp | 4 +- app/src/services/project_service.cpp | 25 ++ app/src/services/project_service.h | 2 + app/src/ui/logic_editor_widget.cpp | 11 - app/src/ui/main_window.cpp | 461 ++++++++++++++++++++- app/src/ui/main_window.h | 27 +- app/src/ui/main_window.ui | 5 + app/src/ui/runtime_monitor_widget.cpp | 47 +++ app/src/ui/runtime_monitor_widget.h | 10 + app/src/ui/runtime_monitor_widget.ui | 3 + app/src/ui/runtime_panel_controller.cpp | 47 +++ app/src/ui/runtime_panel_controller.h | 9 + scripts/package_qt_app.ps1 | 72 +++- 15 files changed, 772 insertions(+), 32 deletions(-) diff --git a/app/integrated_platform.pro b/app/integrated_platform.pro index 4cfae2a..0c7e8b6 100644 --- a/app/integrated_platform.pro +++ b/app/integrated_platform.pro @@ -126,3 +126,8 @@ FORMS += \ src/ui/free_monitor_widget.ui \ src/ui/runtime_monitor_window.ui \ src/ui/runtime_monitor_widget.ui + +# 专用用户运行版打包时由脚本传入生成的工程资源;普通编程器构建不包含工程 +!isEmpty(RUNTIME_PROJECT_RESOURCE) { + RESOURCES += $$RUNTIME_PROJECT_RESOURCE +} diff --git a/app/src/main.cpp b/app/src/main.cpp index eaea52a..3004868 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -7,6 +7,10 @@ */ #include +#include +#include +#include +#include #include "infrastructure/json_project_storage.h" #include "infrastructure/application_settings_loader.h" @@ -41,6 +45,27 @@ int main(int argc, char *argv[]) // 设置组织名称,用来区分这个程序保存的 Qt 配置 application.setOrganizationName(QStringLiteral("QtProXinJe")); + QString runtime_project_path; + const QStringList arguments = application.arguments(); + const int project_option = arguments.indexOf(QStringLiteral("--runtime-project")); + if (project_option >= 0 && project_option + 1 < arguments.size()) + { + runtime_project_path = arguments.at(project_option + 1); + } + const QFileInfo executable_info(application.applicationFilePath()); + const QString bundled_project = executable_info.absolutePath() + + QStringLiteral("/runtime-project.json"); + if (runtime_project_path.isEmpty() + && executable_info.completeBaseName() != QStringLiteral("integrated_platform") + && QFileInfo::exists(bundled_project)) + { + runtime_project_path = bundled_project; + } + const bool embedded_runtime_project = QFile::exists( + QStringLiteral(":/runtime-project.json")); + const bool user_runtime_mode = !runtime_project_path.isEmpty() + || embedded_runtime_project; + const ApplicationSettingsLoadResult application_settings = ApplicationSettingsLoader::load( ApplicationSettingsLoader::defaultFilePath()); @@ -50,6 +75,48 @@ int main(int argc, char *argv[]) // 统一管理新建、打开、保存和校验工程,实际读写文件交给上面的对象 ProjectService project_service( project_storage, application_settings.settings.projectLimits); + if (user_runtime_mode) + { + ProjectOperationResult result; + if (embedded_runtime_project && runtime_project_path.isEmpty()) + { + const QByteArray bytes = [] + { + QFile resource(QStringLiteral(":/runtime-project.json")); + return resource.open(QIODevice::ReadOnly) + ? resource.readAll() : QByteArray{}; + }(); + const QString temporary_path = QDir::tempPath() + + QStringLiteral("/qtproxinje-runtime-project.json"); + QFile temporary_file(temporary_path); + if (bytes.isEmpty() || !temporary_file.open(QIODevice::WriteOnly) + || temporary_file.write(bytes) != bytes.size()) + { + QMessageBox::critical( + nullptr, + QObject::tr("运行程序启动失败"), + QObject::tr("无法读取内嵌项目资源")); + return 2; + } + temporary_file.close(); + result = project_service.load(temporary_path.toUtf8().toStdString()); + QFile::remove(temporary_path); + } + else + { + result = project_service.load( + runtime_project_path.toUtf8().toStdString()); + } + if (!result.succeeded) + { + QMessageBox::critical( + nullptr, + QObject::tr("运行程序启动失败"), + QObject::tr("无法加载项目:%1") + .arg(QString::fromUtf8(result.message.c_str()))); + return 2; + } + } // 处理 HMI 页面和控件的添加、修改、删除、撤销与重做 HmiEditorService hmi_editor_service( project_service, application_settings.settings.hmiDefaults); @@ -111,9 +178,12 @@ int main(int argc, char *argv[]) register_comment_service, register_monitor_service, plc_discovery_service, - application_settings); - // 把主窗口显示到屏幕上 - main_window.show(); + application_settings, + user_runtime_mode); + if (!user_runtime_mode) + { + main_window.show(); + } // 启动 Qt 消息循环,持续响应鼠标、键盘、定时器和串口事件,窗口关闭后才返回 return application.exec(); diff --git a/app/src/services/logic_command_service.cpp b/app/src/services/logic_command_service.cpp index a10615f..d366296 100644 --- a/app/src/services/logic_command_service.cpp +++ b/app/src/services/logic_command_service.cpp @@ -411,9 +411,7 @@ const std::vector &LogicCommandService::suggestions() {"RST", "复位线圈", "M 地址"}, {"MOV", "数据传送", "D 地址 -> D 地址"}, {"ADD", "加法", "D 地址 + D 地址 -> D 地址"}, - {"SUB", "减法", "D 地址 - D 地址 -> D 地址"}, - {"TON", "暂不支持", "T 地址", false}, - {"CTU", "暂不支持", "C 地址", false}}; + {"SUB", "减法", "D 地址 - D 地址 -> D 地址"}}; return values; } diff --git a/app/src/services/project_service.cpp b/app/src/services/project_service.cpp index 42f7f49..a1f9a15 100644 --- a/app/src/services/project_service.cpp +++ b/app/src/services/project_service.cpp @@ -137,6 +137,31 @@ ProjectOperationResult ProjectService::saveAs(const std::string &file_path) return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; } +ProjectOperationResult ProjectService::exportAs(const std::string &file_path) const +{ + if (isBlank(file_path)) + { + return {false, + ProjectServiceError::FilePathRequired, + ProjectStorageError::None, + "必须指定导出文件路径"}; + } + std::string validation_error; + if (!project_.validate(project_limits_, &validation_error)) + { + return {false, + ProjectServiceError::InvalidProject, + ProjectStorageError::InvalidProject, + validation_error}; + } + const ProjectSaveResult result = storage_.save(project_, file_path); + if (!result.succeeded) + { + return storageFailure(result.error, result.message); + } + return {true, ProjectServiceError::None, ProjectStorageError::None, {}}; +} + ProjectOperationResult ProjectService::load(const std::string &file_path) { if (isBlank(file_path)) diff --git a/app/src/services/project_service.h b/app/src/services/project_service.h index a92f64d..ffebca8 100644 --- a/app/src/services/project_service.h +++ b/app/src/services/project_service.h @@ -63,6 +63,8 @@ public: * @return 保存操作结果 */ ProjectOperationResult saveAs(const std::string &file_path); + /** 将当前工程导出到指定 JSON,不改变当前工程文件关联 */ + ProjectOperationResult exportAs(const std::string &file_path) const; /** * @brief 从指定文件加载工程 diff --git a/app/src/ui/logic_editor_widget.cpp b/app/src/ui/logic_editor_widget.cpp index 4e51382..518af2e 100644 --- a/app/src/ui/logic_editor_widget.cpp +++ b/app/src/ui/logic_editor_widget.cpp @@ -1922,17 +1922,6 @@ void LogicEditorWidget::beginCommandInput( command_editor_->raise(); command_editor_->setFocus(Qt::MouseFocusReason); command_editor_->selectAll(); - QTimer::singleShot(0, this, [this] - { - if (command_editor_ == nullptr || !command_editor_->isVisible() - || command_completer_ == nullptr) - { - return; - } - command_completer_->setCompletionPrefix( - commandCompletionPrefix(command_editor_->text())); - command_completer_->complete(command_editor_->rect()); - }); } void LogicEditorWidget::commitCommandInput() diff --git a/app/src/ui/main_window.cpp b/app/src/ui/main_window.cpp index fd5d8e5..cf7986a 100644 --- a/app/src/ui/main_window.cpp +++ b/app/src/ui/main_window.cpp @@ -51,6 +51,11 @@ #include #include #include +#include +#include +#include +#include +#include #include @@ -252,6 +257,56 @@ QToolButton *addToolbarMenu( return button; } +bool copyDirectoryContents( + const QString &source_path, const QString &destination_path, QString *error) +{ + QDir source_directory(source_path); + if (!source_directory.exists()) + { + if (error != nullptr) + { + *error = MainWindow::tr("未找到打包目录:%1").arg(source_path); + } + return false; + } + QDir destination_directory(destination_path); + if (!destination_directory.exists() + && !QDir().mkpath(destination_path)) + { + if (error != nullptr) + { + *error = MainWindow::tr("无法创建导出目录:%1").arg(destination_path); + } + return false; + } + + const QFileInfoList entries = source_directory.entryInfoList( + QDir::NoDotAndDotDot | QDir::AllEntries, + QDir::DirsFirst | QDir::Name); + for (const QFileInfo &entry : entries) + { + const QString destination = destination_directory.filePath(entry.fileName()); + if (entry.isDir()) + { + if (!copyDirectoryContents(entry.absoluteFilePath(), destination, error)) + { + return false; + } + continue; + } + if (QFileInfo::exists(destination) + || !QFile::copy(entry.absoluteFilePath(), destination)) + { + if (error != nullptr) + { + *error = MainWindow::tr("复制打包文件失败:%1").arg(destination); + } + return false; + } + } + return true; +} + } // namespace MainWindow::MainWindow( @@ -266,6 +321,7 @@ MainWindow::MainWindow( RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, const ApplicationSettingsLoadResult &application_settings_result, + bool user_runtime_mode, QWidget *parent) : QMainWindow(parent), ui_(std::make_unique()), @@ -280,6 +336,7 @@ MainWindow::MainWindow( register_monitor_service_(register_monitor_service), plc_discovery_gateway_(plc_discovery_gateway), application_settings_result_(application_settings_result), + user_runtime_mode_(user_runtime_mode), owned_hmi_navigation_service_( std::make_unique(project_service)), hmi_navigation_service_(owned_hmi_navigation_service_.get()), @@ -301,6 +358,7 @@ MainWindow::MainWindow( RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, const ApplicationSettingsLoadResult &application_settings_result, + bool user_runtime_mode, QWidget *parent) : QMainWindow(parent), ui_(std::make_unique()), @@ -315,6 +373,7 @@ MainWindow::MainWindow( register_monitor_service_(register_monitor_service), plc_discovery_gateway_(plc_discovery_gateway), application_settings_result_(application_settings_result), + user_runtime_mode_(user_runtime_mode), hmi_navigation_service_(&hmi_navigation_service), plc_configuration_(application_settings_result.settings.plcDefaults) { @@ -382,10 +441,112 @@ void MainWindow::initializeUi() QMessageBox::warning(this, tr("应用配置"), warning); }); } + if (user_runtime_mode_) + { + initializeUserRuntime(); + } +} + +void MainWindow::initializeUserRuntime() +{ + setWindowTitle(fromUtf8(project_service_.project().metadata.name)); + runtime_panel_controller_->configureUserRuntimeMode( + true, + [this](int mode) + { + if (mode == static_cast(ApplicationMode::OfflineRunning)) + { + requestUserRuntimeMode(ApplicationMode::OfflineRunning); + } + else if (mode == static_cast(ApplicationMode::OnlineRunning)) + { + requestUserRuntimeMode(ApplicationMode::OnlineRunning); + } + }, + [this] { connectPlc(); }, + [this] { disconnectPlc(); }); + menuBar()->setVisible(false); + ui_->hmiToolBar->setVisible(false); + ui_->logicToolBar->setVisible(false); + ui_->outputDock->setVisible(false); + ui_->dataMonitorDock->setVisible(false); + ui_->projectDock->setVisible(false); + ui_->propertiesDock->setVisible(false); + QTimer::singleShot( + 0, + this, + [this] + { + if (requestMode(ApplicationMode::OfflineRunning)) + { + hide(); + } + else + { + qApp->quit(); + } + }); +} + +void MainWindow::requestUserRuntimeMode(ApplicationMode requested_mode) +{ + if (runtime_mode_service_.mode() == requested_mode) + { + return; + } + if (requested_mode == ApplicationMode::OnlineRunning + && !runtime_mode_service_.initialPlcReadCompleted()) + { + // 用户运行版没有可见的编辑器主窗口,失败前不能先隐藏运行监控窗口 + if (runtime_monitor_widget_ != nullptr) + { + runtime_monitor_widget_->setMode( + runtime_mode_service_.mode(), + runtime_mode_service_.plcConnectionState()); + const QString message = transitionErrorText( + ModeTransitionError::InitialPlcReadRequired) + + tr("\n当前状态:%1").arg( + plcStatusText(runtime_mode_service_)) + + tr("\n请先点击“PLC 配置”连接并完成首次读取。"); + QMessageBox::warning( + runtime_monitor_widget_, + tr("无法切换到真机运行"), + message); + } + return; + } + if (runtime_mode_service_.mode() != ApplicationMode::Editing) + { + const ModeTransitionResult editing = runtime_mode_service_.enterEditing(); + if (!editing.succeeded) + { + return; + } + updateModeUi(tr("正在切换运行模式")); + QTimer::singleShot( + 0, + this, + [this, requested_mode] + { + requestMode(requested_mode); + }); + return; + } + requestMode(requested_mode); } MainWindow::~MainWindow() { + if (runtime_export_process_ != nullptr + && runtime_export_process_->state() != QProcess::NotRunning) + { + runtime_export_process_->kill(); + runtime_export_process_->waitForFinished(1000); + } + if (!runtime_export_temp_project_.isEmpty()) + { + QFile::remove(runtime_export_temp_project_); + } qApp->removeEventFilter(this); register_monitor_service_.setAddressesChangedCallback({}); runtime_mode_service_.setPlcStatusChangedCallback({}); @@ -408,6 +569,16 @@ bool MainWindow::eventFilter(QObject *watched, QEvent *event) void MainWindow::closeEvent(QCloseEvent *event) { + if (runtime_export_process_ != nullptr + && runtime_export_process_->state() != QProcess::NotRunning) + { + QMessageBox::information( + this, + tr("正在导出用户运行程序"), + tr("导出尚未完成,请等待打包结束后再关闭编程器")); + event->ignore(); + return; + } if (!confirmSaveBeforeDestructiveAction()) { event->ignore(); @@ -449,6 +620,8 @@ void MainWindow::configureActions() connect(ui_->saveProjectAction, &QAction::triggered, this, &MainWindow::saveProject); connect(ui_->saveAsProjectAction, &QAction::triggered, this, &MainWindow::saveProjectAs); connect(ui_->loadProjectAction, &QAction::triggered, this, &MainWindow::loadProject); + connect(ui_->exportRuntimeAction, &QAction::triggered, + this, &MainWindow::exportRuntimeProgram); connect(ui_->undoAction, &QAction::triggered, this, &MainWindow::undoActiveEditor); connect(ui_->redoAction, &QAction::triggered, @@ -869,7 +1042,14 @@ void MainWindow::configureRuntimeMonitor() }, [this] { - requestMode(ApplicationMode::Editing); + if (user_runtime_mode_) + { + qApp->quit(); + } + else + { + requestMode(ApplicationMode::Editing); + } }); runtime_panel_controller_->configure(); runtime_monitor_widget_ = runtime_panel_controller_->runtimeMonitorWidget(); @@ -1743,6 +1923,277 @@ void MainWindow::loadProject() showProjectResult(tr("加载工程"), tr("工程已加载"), true); } +void MainWindow::exportRuntimeProgram() +{ + if (runtime_export_process_ != nullptr) + { + showProjectResult( + tr("导出用户运行程序"), + tr("已有一个导出任务正在执行,请等待完成"), + false); + return; + } + + std::string validation_error; + if (!project_service_.project().validateForRunning( + project_service_.projectLimits(), &validation_error)) + { + showProjectResult( + tr("导出用户运行程序"), + tr("工程还不能运行:%1").arg(fromUtf8(validation_error)), + false); + return; + } + + QString suggested_name = suggestedProjectFileName( + fromUtf8(project_service_.project().metadata.name)); + suggested_name.chop(QStringLiteral(".json").size()); + const QString parent_directory = QFileDialog::getExistingDirectory( + this, + tr("选择用户运行程序的目标父目录"), + QDir::homePath(), + QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); + if (parent_directory.isEmpty()) + { + return; + } + + bool name_accepted = false; + const QString requested_name = QInputDialog::getText( + this, + tr("导出用户运行程序"), + tr("请输入导出文件夹名称"), + QLineEdit::Normal, + suggested_name, + &name_accepted).trimmed(); + if (!name_accepted || requested_name.isEmpty()) + { + return; + } + const QString output_name = suggestedProjectFileName(requested_name).chopped( + QStringLiteral(".json").size()); + const QString destination_directory = QDir(parent_directory).filePath(output_name); + if (QFileInfo::exists(destination_directory)) + { + if (!QFileInfo(destination_directory).isDir()) + { + showProjectResult( + tr("导出用户运行程序"), + tr("导出目标已经存在且不是文件夹:%1").arg(destination_directory), + false); + return; + } + const QMessageBox::StandardButton answer = QMessageBox::question( + this, + tr("覆盖已有导出目录"), + tr("文件夹“%1”已经存在,是否覆盖?").arg(destination_directory), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No); + if (answer != QMessageBox::Yes) + { + return; + } + if (!QDir(destination_directory).removeRecursively()) + { + showProjectResult( + tr("导出用户运行程序"), + tr("无法清理已有导出目录:%1").arg(destination_directory), + false); + return; + } + } + + const QString temp_project = QDir::tempPath() + + QStringLiteral("/qtproxinje-runtime-") + + QString::number(QCoreApplication::applicationPid()) + + QStringLiteral("-") + + QString::number(QDateTime::currentMSecsSinceEpoch()) + + QStringLiteral(".json"); + const ProjectOperationResult save_result = project_service_.exportAs( + temp_project.toUtf8().toStdString()); + if (!save_result.succeeded) + { + showProjectResult(tr("导出用户运行程序"), fromUtf8(save_result.message), false); + return; + } + + QString script_path; + QDir probe(QCoreApplication::applicationDirPath()); + for (int depth = 0; depth < 5 && script_path.isEmpty(); ++depth) + { + const QString candidate = probe.filePath(QStringLiteral("scripts/package_qt_app.ps1")); + if (QFileInfo::exists(candidate)) + { + script_path = QDir::toNativeSeparators(candidate); + break; + } + if (!probe.cdUp()) + { + break; + } + } + if (script_path.isEmpty()) + { + QFile::remove(temp_project); + showProjectResult( + tr("导出用户运行程序"), + tr("未找到打包脚本,请确认程序安装目录完整"), + false); + return; + } + + runtime_export_temp_project_ = temp_project; + runtime_export_output_name_ = output_name; + runtime_export_destination_directory_ = destination_directory; + runtime_export_package_directory_ = QDir(probe.filePath(QStringLiteral("build/package"))) + .filePath(output_name); + runtime_export_process_output_.clear(); + runtime_export_completion_handled_ = false; + + runtime_export_progress_ = std::make_unique( + tr("正在准备导出用户运行程序…"), + QString(), + 0, + 100, + this); + runtime_export_progress_->setWindowTitle(tr("导出用户运行程序")); + runtime_export_progress_->setWindowModality(Qt::WindowModal); + runtime_export_progress_->setAutoClose(false); + runtime_export_progress_->setAutoReset(false); + runtime_export_progress_->setMinimumDuration(0); + runtime_export_progress_->setValue(5); + runtime_export_progress_->show(); + + runtime_export_process_ = std::make_unique(this); + runtime_export_process_->setProgram(QStringLiteral("pwsh")); + runtime_export_process_->setArguments({ + QStringLiteral("-NoLogo"), QStringLiteral("-NoProfile"), + QStringLiteral("-File"), script_path, + QStringLiteral("-OutputName"), output_name, + QStringLiteral("-ProjectFile"), temp_project}); + connect( + runtime_export_process_.get(), + &QProcess::readyReadStandardOutput, + this, + &MainWindow::handleRuntimeExportOutput); + connect( + runtime_export_process_.get(), + &QProcess::readyReadStandardError, + this, + &MainWindow::handleRuntimeExportOutput); + connect( + runtime_export_process_.get(), + qOverload(&QProcess::finished), + this, + &MainWindow::handleRuntimeExportFinished); + connect( + runtime_export_process_.get(), + &QProcess::errorOccurred, + this, + [this](QProcess::ProcessError error) + { + if (error == QProcess::FailedToStart) + { + handleRuntimeExportFinished(-1, QProcess::CrashExit); + } + }); + + ui_->exportRuntimeAction->setEnabled(false); + statusBar()->showMessage(tr("正在构建用户运行程序,请稍候…"), 0); + runtime_export_process_->start(); +} + +void MainWindow::handleRuntimeExportOutput() +{ + if (runtime_export_process_ == nullptr) + { + return; + } + runtime_export_process_output_ += QString::fromUtf8( + runtime_export_process_->readAllStandardOutput()); + runtime_export_process_output_ += QString::fromUtf8( + runtime_export_process_->readAllStandardError()); + + if (runtime_export_progress_ == nullptr) + { + return; + } + if (runtime_export_process_output_.contains(QStringLiteral("RUNTIME_EXPORT_STAGE: qmake"))) + { + runtime_export_progress_->setValue(20); + runtime_export_progress_->setLabelText(tr("正在生成 Qt 构建文件…")); + } + if (runtime_export_process_output_.contains(QStringLiteral("RUNTIME_EXPORT_STAGE: compile"))) + { + runtime_export_progress_->setValue(35); + runtime_export_progress_->setLabelText(tr("正在编译用户运行程序,这一步可能需要较长时间…")); + } + if (runtime_export_process_output_.contains(QStringLiteral("RUNTIME_EXPORT_STAGE: package"))) + { + runtime_export_progress_->setValue(85); + runtime_export_progress_->setLabelText(tr("正在复制 Qt 运行库和平台插件…")); + } + if (runtime_export_process_output_.contains(QStringLiteral("RUNTIME_EXPORT_STAGE: verify"))) + { + runtime_export_progress_->setValue(95); + runtime_export_progress_->setLabelText(tr("正在校验导出结果…")); + } +} + +void MainWindow::handleRuntimeExportFinished( + int exit_code, QProcess::ExitStatus exit_status) +{ + if (runtime_export_completion_handled_) + { + return; + } + runtime_export_completion_handled_ = true; + handleRuntimeExportOutput(); + + const QString temp_project = runtime_export_temp_project_; + const QString output_name = runtime_export_output_name_; + const QString destination_directory = runtime_export_destination_directory_; + const QString package_directory = runtime_export_package_directory_; + const QString process_output = runtime_export_process_output_.trimmed(); + const bool process_succeeded = exit_status == QProcess::NormalExit && exit_code == 0; + + bool succeeded = process_succeeded; + QString result_message; + if (!succeeded) + { + result_message = process_output.isEmpty() + ? tr("打包进程未能正常完成") + : tr("打包失败:%1").arg(process_output); + } + else + { + runtime_export_progress_->setValue(98); + runtime_export_progress_->setLabelText(tr("正在整理导出目录…")); + QString copy_error; + succeeded = copyDirectoryContents(package_directory, destination_directory, ©_error); + result_message = succeeded + ? tr("已导出:%1").arg(QDir(destination_directory).filePath(output_name + QStringLiteral(".exe"))) + : copy_error; + } + + QFile::remove(temp_project); + if (runtime_export_progress_ != nullptr) + { + runtime_export_progress_->setValue(succeeded ? 100 : 0); + runtime_export_progress_->close(); + runtime_export_progress_.reset(); + } + runtime_export_process_.reset(); + runtime_export_temp_project_.clear(); + runtime_export_output_name_.clear(); + runtime_export_destination_directory_.clear(); + runtime_export_package_directory_.clear(); + runtime_export_process_output_.clear(); + ui_->exportRuntimeAction->setEnabled( + runtime_mode_service_.policy().allowsProjectEditing); + showProjectResult(tr("导出用户运行程序"), result_message, succeeded); +} + void MainWindow::showProjectResult( const QString &action, const QString &message, bool succeeded) { @@ -1767,7 +2218,7 @@ void MainWindow::appendOutputMessage(const QString &message) ui_->outputList->scrollToBottom(); } -void MainWindow::requestMode(ApplicationMode requested_mode) +bool MainWindow::requestMode(ApplicationMode requested_mode) { // UI 动作只提出目标模式,所有前置条件和仓库切换由 RuntimeModeService 决定 // UI 仅转发模式意图,合法性由服务层和领域状态机决定 @@ -1793,7 +2244,7 @@ void MainWindow::requestMode(ApplicationMode requested_mode) { restoreCurrentModeAction(); statusBar()->showMessage(tr("不支持的运行模式"), 5000); - return; + return false; } } if (!result.succeeded) @@ -1816,9 +2267,10 @@ void MainWindow::requestMode(ApplicationMode requested_mode) } } statusBar()->showMessage(message, 5000); - return; + return false; } updateModeUi(tr("已进入%1").arg(modeText(runtime_mode_service_.mode()))); + return true; } void MainWindow::updateModeUi(const QString &message) @@ -1893,6 +2345,7 @@ void MainWindow::updateModeUi(const QString &message) ui_->saveProjectAction->setEnabled(policy.allowsProjectEditing); ui_->saveAsProjectAction->setEnabled(policy.allowsProjectEditing); ui_->loadProjectAction->setEnabled(policy.allowsProjectEditing); + ui_->exportRuntimeAction->setEnabled(policy.allowsProjectEditing); updateProjectTreeActions(); updateEditActions(); diff --git a/app/src/ui/main_window.h b/app/src/ui/main_window.h index d38c4c2..22871a9 100644 --- a/app/src/ui/main_window.h +++ b/app/src/ui/main_window.h @@ -15,6 +15,7 @@ #include "services/application_settings.h" #include +#include #include #include @@ -28,6 +29,7 @@ class QEvent; class QLabel; class QDockWidget; class QTimer; +class QProgressDialog; namespace Ui { class MainWindow; @@ -84,6 +86,7 @@ public: RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, const ApplicationSettingsLoadResult &application_settings_result, + bool user_runtime_mode = false, QWidget *parent = nullptr); /** * @brief 创建主窗口,并使用调用方提供的 HMI 页面导航服务 @@ -103,6 +106,7 @@ public: RegisterMonitorService ®ister_monitor_service, PlcDiscoveryGateway &plc_discovery_gateway, const ApplicationSettingsLoadResult &application_settings_result, + bool user_runtime_mode = false, QWidget *parent = nullptr); /** 释放窗口拥有的控制器和可选导航服务 */ @@ -210,6 +214,12 @@ private: void saveProjectAs(); /** 从用户指定的路径加载工程 */ void loadProject(); + /** 将当前工程构建为用户可直接运行的专用 exe */ + void exportRuntimeProgram(); + /** 更新用户运行程序导出的阶段进度 */ + void handleRuntimeExportOutput(); + /** 完成用户运行程序导出并复制最终目录 */ + void handleRuntimeExportFinished(int exit_code, QProcess::ExitStatus exit_status); /** 在状态栏和输出面板显示工程操作结果 */ void showProjectResult(const QString &action, const QString &message, bool succeeded); /** 向输出面板追加一条消息 */ @@ -221,7 +231,7 @@ private: * * 失败时恢复与服务层当前状态一致的工具栏选项 */ - void requestMode(ApplicationMode requested_mode); + bool requestMode(ApplicationMode requested_mode); /** * @brief 根据服务层模式策略更新编辑权限和状态反馈 @@ -237,6 +247,10 @@ private: void restoreCurrentModeAction(); /** 完成主窗口初始化和首次界面刷新 */ void initializeUi(); + /** 专用用户运行版启动后直接进入运行监控 */ + void initializeUserRuntime(); + /** 用户运行版允许在离线和真机之间自动经过编辑态切换 */ + void requestUserRuntimeMode(ApplicationMode requested_mode); /** 刷新编辑态数据监控的模式、权限和当前值 */ void refreshDataMonitorUi(); @@ -268,6 +282,8 @@ private: PlcDiscoveryGateway &plc_discovery_gateway_; /** 启动期配置和诊断结果,由应用入口保证生命周期 */ const ApplicationSettingsLoadResult &application_settings_result_; + /** 是否以专用用户运行版入口启动 */ + bool user_runtime_mode_ = false; /** 当调用方未提供导航服务时由主窗口负责拥有的实例 */ std::unique_ptr owned_hmi_navigation_service_; /** 当前使用的 HMI 页面导航服务 */ @@ -308,6 +324,15 @@ private: std::string current_logic_id_; /** 是否已经安排了待处理的 PLC 状态刷新 */ bool plc_status_update_pending_ = false; + /** 用户运行程序导出进程及其临时状态 */ + std::unique_ptr runtime_export_process_; + std::unique_ptr runtime_export_progress_; + QString runtime_export_temp_project_; + QString runtime_export_output_name_; + QString runtime_export_destination_directory_; + QString runtime_export_package_directory_; + QString runtime_export_process_output_; + bool runtime_export_completion_handled_ = false; struct HmiClipboardData { diff --git a/app/src/ui/main_window.ui b/app/src/ui/main_window.ui index 6ce7a6f..a1cd086 100644 --- a/app/src/ui/main_window.ui +++ b/app/src/ui/main_window.ui @@ -228,6 +228,7 @@ + @@ -1003,6 +1004,10 @@ Ctrl+O + + 导出用户运行程序 + 将当前工程导出为用户可直接运行的专用程序 + 撤销 撤销当前编辑器的上一步操作 diff --git a/app/src/ui/runtime_monitor_widget.cpp b/app/src/ui/runtime_monitor_widget.cpp index de6c6b6..1b1c96a 100644 --- a/app/src/ui/runtime_monitor_widget.cpp +++ b/app/src/ui/runtime_monitor_widget.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -67,6 +68,9 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( free_monitor_widget_->setObjectName(QStringLiteral("freeMonitorWidget")); ui_->monitorContainerLayout->addWidget(free_monitor_widget_); ui_->logicNoticeLabel->setVisible(false); + ui_->runtimeModeComboBox->setVisible(false); + ui_->runtimePlcButton->setVisible(false); + ui_->runtimeDisconnectPlcButton->setVisible(false); ui_->upperSplitter->setSizes({600, 600}); ui_->verticalSplitter->setSizes({440, 260}); connect(hmi_view_, &HmiEditorWidget::pageNavigationRequested, @@ -95,6 +99,22 @@ RuntimeMonitorWidget::RuntimeMonitorWidget( ui_->runtimeLogicComboBox->itemData(index).toString())); } }); + connect(ui_->runtimeModeComboBox, + QOverload::of(&QComboBox::currentIndexChanged), + this, + [this](int index) + { + if (user_runtime_mode_ && index >= 0) + { + emit modeChangeRequested(index == 0 + ? static_cast(ApplicationMode::OfflineRunning) + : static_cast(ApplicationMode::OnlineRunning)); + } + }); + connect(ui_->runtimePlcButton, &QPushButton::clicked, + this, &RuntimeMonitorWidget::plcConfigurationRequested); + connect(ui_->runtimeDisconnectPlcButton, &QPushButton::clicked, + this, &RuntimeMonitorWidget::plcDisconnectRequested); } RuntimeMonitorWidget::~RuntimeMonitorWidget() = default; @@ -157,9 +177,36 @@ void RuntimeMonitorWidget::setMode( offline ? tr("离线仿真 · 虚拟 M/D") : online ? tr("真机运行 · PLC 缓存 · 本地推算轨迹") : tr("当前未运行")); + if (user_runtime_mode_) + { + const QSignalBlocker blocker(ui_->runtimeModeComboBox); + ui_->runtimeModeComboBox->setCurrentIndex( + offline ? 0 : online ? 1 : -1); + const bool plc_configurable = plc_state == PlcConnectionState::Disconnected + || plc_state == PlcConnectionState::Faulted; + ui_->runtimePlcButton->setEnabled(plc_configurable); + ui_->runtimeDisconnectPlcButton->setEnabled( + plc_state != PlcConnectionState::Disconnected); + } free_monitor_widget_->refreshValues(mode, plc_state); } +void RuntimeMonitorWidget::setUserRuntimeMode(bool enabled) +{ + user_runtime_mode_ = enabled; + ui_->runtimeModeComboBox->setVisible(enabled); + ui_->runtimePlcButton->setVisible(enabled); + ui_->runtimeDisconnectPlcButton->setVisible(enabled); + ui_->exitRuntimeButton->setToolTip( + enabled ? tr("退出运行程序") : tr("返回编辑态")); + ui_->exitRuntimeButton->setAccessibleName( + enabled ? tr("退出运行程序") : tr("返回编辑态")); + if (enabled) + { + setMode(ApplicationMode::Editing, PlcConnectionState::Disconnected); + } +} + void RuntimeMonitorWidget::setHmiWriteEnabled(bool enabled) { hmi_view_->setRuntimeWriteEnabled(enabled); diff --git a/app/src/ui/runtime_monitor_widget.h b/app/src/ui/runtime_monitor_widget.h index 52f4b75..1c9bd72 100644 --- a/app/src/ui/runtime_monitor_widget.h +++ b/app/src/ui/runtime_monitor_widget.h @@ -57,6 +57,8 @@ public: void setRuntimePage(const std::string &page_id); /** 根据模式决定是否显示本地逻辑轨迹以及是否允许写入 */ void setMode(ApplicationMode mode, PlcConnectionState plc_state); + /** 切换专用用户运行版的简化操作栏 */ + void setUserRuntimeMode(bool enabled); /** 从寄存器仓库刷新 HMI 和自由监控中的当前值 */ void refreshValues(ApplicationMode mode, PlcConnectionState plc_state); /** 设置 HMI 控件是否允许写入运行值 */ @@ -75,6 +77,12 @@ signals: void navigationFailed(const QString &message); /** 用户请求退出运行态 */ void exitRequested(); + /** 用户在专用运行版中请求切换模式 */ + void modeChangeRequested(int mode); + /** 用户请求打开 PLC 配置窗口 */ + void plcConfigurationRequested(); + /** 用户请求断开 PLC */ + void plcDisconnectRequested(); private: /** 显示指定的运行监控页面 */ @@ -102,4 +110,6 @@ private: std::string latest_fault_node_id_; /** 是否已经收到过可显示的逻辑轨迹 */ bool has_logic_trace_ = false; + /** 是否显示专用用户运行版操作栏 */ + bool user_runtime_mode_ = false; }; diff --git a/app/src/ui/runtime_monitor_widget.ui b/app/src/ui/runtime_monitor_widget.ui index 9564e95..f102990 100644 --- a/app/src/ui/runtime_monitor_widget.ui +++ b/app/src/ui/runtime_monitor_widget.ui @@ -13,6 +13,9 @@ 运行监控font-weight: 600; Qt::Horizontal4020 当前未运行Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + 1200离线仿真真机运行 + PLC 配置 + 断开 PLC 返回编辑态返回编辑态返回编辑态Qt::ToolButtonIconOnly diff --git a/app/src/ui/runtime_panel_controller.cpp b/app/src/ui/runtime_panel_controller.cpp index 06b6936..41940b1 100644 --- a/app/src/ui/runtime_panel_controller.cpp +++ b/app/src/ui/runtime_panel_controller.cpp @@ -121,6 +121,36 @@ void RuntimePanelController::configure() status_reporter_(message, 5000); output_reporter_(QObject::tr("页面导航失败:%1").arg(message)); }); + QObject::connect(runtime_monitor_widget_, + &RuntimeMonitorWidget::modeChangeRequested, + &parent_, + [this](int mode) + { + if (mode_requester_) + { + mode_requester_(mode); + } + }); + QObject::connect(runtime_monitor_widget_, + &RuntimeMonitorWidget::plcConfigurationRequested, + &parent_, + [this] + { + if (plc_configuration_requester_) + { + plc_configuration_requester_(); + } + }); + QObject::connect(runtime_monitor_widget_, + &RuntimeMonitorWidget::plcDisconnectRequested, + &parent_, + [this] + { + if (plc_disconnect_requester_) + { + plc_disconnect_requester_(); + } + }); runtime_refresh_timer_ = new QTimer(&parent_); runtime_refresh_timer_->setInterval(150); QObject::connect(runtime_refresh_timer_, &QTimer::timeout, @@ -143,6 +173,21 @@ void RuntimePanelController::configure() Qt::QueuedConnection); } +void RuntimePanelController::configureUserRuntimeMode( + bool enabled, + std::function mode_requester, + std::function plc_configuration_requester, + std::function plc_disconnect_requester) +{ + mode_requester_ = std::move(mode_requester); + plc_configuration_requester_ = std::move(plc_configuration_requester); + plc_disconnect_requester_ = std::move(plc_disconnect_requester); + if (runtime_monitor_widget_ != nullptr) + { + runtime_monitor_widget_->setUserRuntimeMode(enabled); + } +} + RuntimeMonitorWidget *RuntimePanelController::runtimeMonitorWidget() const { return runtime_monitor_widget_; @@ -162,6 +207,8 @@ void RuntimePanelController::enterRuntime( } runtime_monitor_widget_->freeMonitorWidget()->reloadAddresses(); runtime_monitor_widget_->setMode(mode, plc_state); + runtime_monitor_window_->setWindowTitle( + fromUtf8(project_service_.project().metadata.name)); runtime_monitor_window_->showForRuntime(); } diff --git a/app/src/ui/runtime_panel_controller.h b/app/src/ui/runtime_panel_controller.h index efc8b48..98505dc 100644 --- a/app/src/ui/runtime_panel_controller.h +++ b/app/src/ui/runtime_panel_controller.h @@ -70,6 +70,12 @@ public: /** 连接运行监控相关信号并完成初始配置 */ void configure(); + /** 配置专用用户运行版的精简控制栏行为 */ + void configureUserRuntimeMode( + bool enabled, + std::function mode_requester, + std::function plc_configuration_requester, + std::function plc_disconnect_requester); /** 创建或复用唯一监控窗口,并把当前工程投影到运行态 */ void enterRuntime( const std::string &page_id, @@ -137,4 +143,7 @@ private: QTimer *runtime_refresh_timer_ = nullptr; /** 当前是否已经进入运行监控会话 */ bool runtime_session_active_ = false; + std::function mode_requester_; + std::function plc_configuration_requester_; + std::function plc_disconnect_requester_; }; diff --git a/scripts/package_qt_app.ps1 b/scripts/package_qt_app.ps1 index 7e0d14e..182120d 100644 --- a/scripts/package_qt_app.ps1 +++ b/scripts/package_qt_app.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( - [string]$OutputName = 'integrated_platform-win64' + [string]$OutputName = 'integrated_platform-win64', + [string]$ProjectFile = '' ) Set-StrictMode -Version Latest @@ -14,12 +15,13 @@ $makePath = Join-Path $mingwBin 'mingw32-make.exe' $pluginDirectory = Join-Path $qtRoot 'plugins' $platformPlugin = Join-Path $pluginDirectory 'platforms\qwindows.dll' $stylePlugin = Join-Path $pluginDirectory 'styles\qwindowsvistastyle.dll' -$projectFile = Join-Path $projectRoot 'app\integrated_platform.pro' -$buildDirectory = Join-Path $projectRoot 'build\package-build\release' +$qtProjectFile = Join-Path $projectRoot 'app\integrated_platform.pro' +$runtimeBuildRoot = Join-Path $projectRoot 'build\runtime-export' +$buildDirectory = Join-Path $runtimeBuildRoot ([guid]::NewGuid().ToString('N')) $executablePath = Join-Path $buildDirectory 'release\integrated_platform.exe' $packageRoot = Join-Path $projectRoot 'build\package' -foreach ($requiredPath in @($qmakePath, $makePath, $platformPlugin, $stylePlugin, $projectFile)) { +foreach ($requiredPath in @($qmakePath, $makePath, $platformPlugin, $stylePlugin, $qtProjectFile)) { if (-not (Test-Path -LiteralPath $requiredPath)) { throw "未找到必要文件:$requiredPath" } @@ -32,6 +34,13 @@ if ([string]::IsNullOrWhiteSpace($OutputName) -or throw "输出名称不是有效的 Windows 文件名:$OutputName" } +if (-not [string]::IsNullOrWhiteSpace($ProjectFile)) { + $resolvedProjectFile = [System.IO.Path]::GetFullPath($ProjectFile) + if (-not (Test-Path -LiteralPath $resolvedProjectFile -PathType Leaf)) { + throw "未找到运行版工程文件:$resolvedProjectFile" + } +} + $packageDirectory = Join-Path $packageRoot $OutputName $archivePath = Join-Path $packageRoot "$OutputName.zip" $resolvedProjectRoot = [System.IO.Path]::GetFullPath($projectRoot).TrimEnd('\') @@ -45,17 +54,39 @@ if (-not $resolvedPackageRoot.StartsWith("$resolvedProjectRoot\build\", [System. New-Item -ItemType Directory -Path $buildDirectory -Force | Out-Null New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null +$runtimeResourceFile = $null +if (-not [string]::IsNullOrWhiteSpace($ProjectFile)) { + $runtimeResourceFile = Join-Path $buildDirectory 'runtime_project.qrc' + $escapedProjectPath = [System.Security.SecurityElement]::Escape($resolvedProjectFile) + @( + '' + '' + "$escapedProjectPath" + '' + ) | Set-Content -LiteralPath $runtimeResourceFile -Encoding UTF8 + $resourceText = Get-Content -LiteralPath $runtimeResourceFile -Raw -Encoding UTF8 + if (-not $resourceText.Contains($escapedProjectPath)) { + throw "运行版资源未正确指向工程文件:$resolvedProjectFile" + } +} + $originalPath = $env:Path $env:Path = "$qtRoot\bin;$mingwBin;$env:Path" try { Push-Location $buildDirectory try { - & $qmakePath $projectFile 'CONFIG+=release' 'CONFIG-=debug' + Write-Host 'RUNTIME_EXPORT_STAGE: qmake' + $qmakeArguments = @($qtProjectFile, 'CONFIG+=release', 'CONFIG-=debug') + if ($null -ne $runtimeResourceFile) { + $qmakeArguments += "RUNTIME_PROJECT_RESOURCE=$($runtimeResourceFile.Replace('\', '/'))" + } + & $qmakePath @qmakeArguments if ($LASTEXITCODE -ne 0) { throw "qmake 执行失败,退出码:$LASTEXITCODE" } + Write-Host 'RUNTIME_EXPORT_STAGE: compile' & $makePath -j2 if ($LASTEXITCODE -ne 0) { throw "mingw32-make 执行失败,退出码:$LASTEXITCODE" @@ -69,6 +100,17 @@ try { throw "未生成可执行文件:$executablePath" } + if ($null -ne $runtimeResourceFile) { + $generatedResourceSource = Join-Path $buildDirectory 'release\qrc_runtime_project.cpp' + if (-not (Test-Path -LiteralPath $generatedResourceSource)) { + throw "未生成运行版工程资源:$generatedResourceSource" + } + $generatedResourceText = Get-Content -LiteralPath $generatedResourceSource -Raw -Encoding UTF8 + if ($generatedResourceText -notlike '*runtime-project.json*') { + throw '编译结果中缺少 runtime-project.json 资源' + } + } + if (Test-Path -LiteralPath $packageDirectory) { Remove-Item -LiteralPath $packageDirectory -Recurse -Force } @@ -76,10 +118,15 @@ try { Remove-Item -LiteralPath $archivePath -Force } + Write-Host 'RUNTIME_EXPORT_STAGE: package' New-Item -ItemType Directory -Path $packageDirectory | Out-Null - Copy-Item -LiteralPath $executablePath -Destination $packageDirectory - - $packagedExecutable = Join-Path $packageDirectory 'integrated_platform.exe' + $packagedExecutableName = if ([string]::IsNullOrWhiteSpace($ProjectFile)) { + 'integrated_platform.exe' + } else { + "$OutputName.exe" + } + $packagedExecutable = Join-Path $packageDirectory $packagedExecutableName + Copy-Item -LiteralPath $executablePath -Destination $packagedExecutable $runtimeFiles = @( 'Qt5Core.dll', 'Qt5Gui.dll', 'Qt5Network.dll', 'Qt5SerialBus.dll', 'Qt5SerialPort.dll', 'Qt5Widgets.dll', @@ -100,6 +147,7 @@ try { Copy-Item -LiteralPath $platformPlugin -Destination $packagedPlatformDirectory Copy-Item -LiteralPath $stylePlugin -Destination $packagedStyleDirectory + Write-Host 'RUNTIME_EXPORT_STAGE: verify' foreach ($requiredOutput in @( $packagedExecutable, (Join-Path $packageDirectory 'Qt5Core.dll'), @@ -111,11 +159,15 @@ try { } } - Compress-Archive -LiteralPath $packageDirectory -DestinationPath $archivePath -CompressionLevel Optimal + if ([string]::IsNullOrWhiteSpace($ProjectFile)) { + Compress-Archive -LiteralPath $packageDirectory -DestinationPath $archivePath -CompressionLevel Optimal + } Write-Host "打包完成" Write-Host "运行目录:$packageDirectory" - Write-Host "压缩包:$archivePath" + if ([string]::IsNullOrWhiteSpace($ProjectFile)) { + Write-Host "压缩包:$archivePath" + } } finally { $env:Path = $originalPath