| @@ -22,7 +22,9 @@ SOURCES += \ | |||||
| src/domain/hmi_model.cpp \ | src/domain/hmi_model.cpp \ | ||||
| src/domain/control_logic_model.cpp \ | src/domain/control_logic_model.cpp \ | ||||
| src/domain/project_model.cpp \ | src/domain/project_model.cpp \ | ||||
| src/domain/runtime_state.cpp | |||||
| src/domain/runtime_state.cpp \ | |||||
| src/services/project_service.cpp \ | |||||
| src/infrastructure/json_project_storage.cpp | |||||
| HEADERS += \ | HEADERS += \ | ||||
| src/ui/main_window.h \ | src/ui/main_window.h \ | ||||
| @@ -31,7 +33,10 @@ HEADERS += \ | |||||
| src/domain/hmi_model.h \ | src/domain/hmi_model.h \ | ||||
| src/domain/control_logic_model.h \ | src/domain/control_logic_model.h \ | ||||
| src/domain/project_model.h \ | src/domain/project_model.h \ | ||||
| src/domain/runtime_state.h | |||||
| src/domain/runtime_state.h \ | |||||
| src/domain/project_storage.h \ | |||||
| src/services/project_service.h \ | |||||
| src/infrastructure/json_project_storage.h | |||||
| FORMS += \ | FORMS += \ | ||||
| src/ui/main_window.ui | src/ui/main_window.ui | ||||
| @@ -0,0 +1,63 @@ | |||||
| #pragma once | |||||
| #include "project_model.h" | |||||
| #include <string> | |||||
| namespace integrated_platform::domain { | |||||
| // 工程文件存储失败原因 | |||||
| enum class ProjectStorageError | |||||
| { | |||||
| None, // 无错误 | |||||
| FileOpenFailed, // 文件打开失败 | |||||
| FileReadFailed, // 文件读取失败 | |||||
| FileWriteFailed, // 文件写入失败 | |||||
| InvalidJson, // JSON 格式无效 | |||||
| MissingField, // 缺少必要字段 | |||||
| InvalidField, // 字段值无效 | |||||
| UnsupportedVersion, // 工程版本不受支持 | |||||
| InvalidProject // 工程内容无效 | |||||
| }; | |||||
| // 工程保存结果 | |||||
| struct ProjectSaveResult | |||||
| { | |||||
| bool succeeded = false; // 保存是否成功 | |||||
| ProjectStorageError error = ProjectStorageError::None; // 保存失败原因 | |||||
| std::string message; // 结果描述信息 | |||||
| }; | |||||
| // 工程加载结果 | |||||
| struct ProjectLoadResult | |||||
| { | |||||
| bool succeeded = false; // 加载是否成功 | |||||
| Project project; // 加载得到的工程 | |||||
| ProjectStorageError error = ProjectStorageError::None; // 加载失败原因 | |||||
| std::string message; // 结果描述信息 | |||||
| }; | |||||
| // 工程文件存储边界 | |||||
| class ProjectStorage | |||||
| { | |||||
| public: | |||||
| virtual ~ProjectStorage() = default; | |||||
| /** | |||||
| * @brief 将工程保存到指定文件 | |||||
| * @param project 需要保存的有效工程 | |||||
| * @param file_path UTF-8 编码的目标文件路径 | |||||
| * @return 工程保存结果 | |||||
| */ | |||||
| virtual ProjectSaveResult save( | |||||
| const Project &project, const std::string &file_path) = 0; | |||||
| /** | |||||
| * @brief 从指定文件加载工程 | |||||
| * @param file_path UTF-8 编码的工程文件路径 | |||||
| * @return 工程加载结果 | |||||
| */ | |||||
| virtual ProjectLoadResult load(const std::string &file_path) = 0; | |||||
| }; | |||||
| } // namespace integrated_platform::domain | |||||
| @@ -1 +0,0 @@ | |||||
| @@ -0,0 +1,16 @@ | |||||
| #pragma once | |||||
| #include "domain/project_storage.h" | |||||
| namespace integrated_platform::infrastructure { | |||||
| // 使用版本化 JSON 文件保存和加载工程 | |||||
| class JsonProjectStorage final : public domain::ProjectStorage | |||||
| { | |||||
| public: | |||||
| domain::ProjectSaveResult save( | |||||
| const domain::Project &project, const std::string &file_path) override; | |||||
| domain::ProjectLoadResult load(const std::string &file_path) override; | |||||
| }; | |||||
| } // namespace integrated_platform::infrastructure | |||||
| @@ -1 +0,0 @@ | |||||
| @@ -0,0 +1,181 @@ | |||||
| #include "project_service.h" | |||||
| #include <algorithm> | |||||
| #include <chrono> | |||||
| #include <cctype> | |||||
| #include <random> | |||||
| #include <sstream> | |||||
| #include <utility> | |||||
| namespace integrated_platform::services { | |||||
| namespace { | |||||
| // 使用高精度时间戳和随机值生成工程标识 | |||||
| std::string generateProjectId() | |||||
| { | |||||
| const auto timestamp = static_cast<unsigned long long>( | |||||
| std::chrono::high_resolution_clock::now().time_since_epoch().count()); | |||||
| std::random_device random_device; | |||||
| const auto random_value = static_cast<unsigned long long>(random_device()); | |||||
| std::ostringstream stream; | |||||
| stream << "project-" << std::hex << timestamp << '-' << random_value; | |||||
| return stream.str(); | |||||
| } | |||||
| } // namespace | |||||
| ProjectService::ProjectService(domain::ProjectStorage &storage) | |||||
| : storage_(storage), | |||||
| project_(makeNewProject("Untitled")) | |||||
| { | |||||
| } | |||||
| const domain::Project &ProjectService::project() const | |||||
| { | |||||
| return project_; | |||||
| } | |||||
| domain::Project &ProjectService::editProject() | |||||
| { | |||||
| modified_ = true; | |||||
| return project_; | |||||
| } | |||||
| const std::string &ProjectService::currentFilePath() const | |||||
| { | |||||
| return current_file_path_; | |||||
| } | |||||
| bool ProjectService::hasCurrentFile() const | |||||
| { | |||||
| return !current_file_path_.empty(); | |||||
| } | |||||
| bool ProjectService::isModified() const | |||||
| { | |||||
| return modified_; | |||||
| } | |||||
| ProjectOperationResult ProjectService::createNewProject(const std::string &name) | |||||
| { | |||||
| if (isBlank(name)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::InvalidProjectName, | |||||
| domain::ProjectStorageError::None, | |||||
| "project name must not be empty"}; | |||||
| } | |||||
| // 新工程创建成功后清除原文件关联,并标记为待保存 | |||||
| project_ = makeNewProject(name); | |||||
| current_file_path_.clear(); | |||||
| modified_ = true; | |||||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectOperationResult ProjectService::save() | |||||
| { | |||||
| if (!hasCurrentFile()) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::FilePathRequired, | |||||
| domain::ProjectStorageError::None, | |||||
| "project file path is required"}; | |||||
| } | |||||
| return saveAs(current_file_path_); | |||||
| } | |||||
| ProjectOperationResult ProjectService::saveAs(const std::string &file_path) | |||||
| { | |||||
| if (isBlank(file_path)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::FilePathRequired, | |||||
| domain::ProjectStorageError::None, | |||||
| "project file path is required"}; | |||||
| } | |||||
| std::string validation_error; | |||||
| if (!project_.validate(&validation_error)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::InvalidProject, | |||||
| domain::ProjectStorageError::InvalidProject, | |||||
| validation_error}; | |||||
| } | |||||
| // 先完成领域校验,避免将非法工程交给存储层 | |||||
| const domain::ProjectSaveResult result = storage_.save(project_, file_path); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return storageFailure(result.error, result.message); | |||||
| } | |||||
| // 只有存储成功后才更新文件关联和未保存状态 | |||||
| current_file_path_ = file_path; | |||||
| modified_ = false; | |||||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||||
| } | |||||
| ProjectOperationResult ProjectService::load(const std::string &file_path) | |||||
| { | |||||
| if (isBlank(file_path)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::FilePathRequired, | |||||
| domain::ProjectStorageError::None, | |||||
| "project file path is required"}; | |||||
| } | |||||
| domain::ProjectLoadResult result = storage_.load(file_path); | |||||
| if (!result.succeeded) | |||||
| { | |||||
| return storageFailure(result.error, result.message); | |||||
| } | |||||
| std::string validation_error; | |||||
| if (!result.project.validate(&validation_error)) | |||||
| { | |||||
| return {false, | |||||
| ProjectServiceError::InvalidProject, | |||||
| domain::ProjectStorageError::InvalidProject, | |||||
| validation_error}; | |||||
| } | |||||
| // 文件读取和领域校验均成功后,才替换当前工程状态 | |||||
| project_ = std::move(result.project); | |||||
| current_file_path_ = file_path; | |||||
| modified_ = false; | |||||
| return {true, ProjectServiceError::None, domain::ProjectStorageError::None, {}}; | |||||
| } | |||||
| domain::Project ProjectService::makeNewProject(const std::string &name) | |||||
| { | |||||
| domain::Project project; | |||||
| project.metadata.id = generateProjectId(); | |||||
| project.metadata.name = name; | |||||
| project.metadata.formatVersion = "1.0"; | |||||
| return project; | |||||
| } | |||||
| bool ProjectService::isBlank(const std::string &value) | |||||
| { | |||||
| return value.empty() | |||||
| || std::all_of( | |||||
| value.cbegin(), | |||||
| value.cend(), | |||||
| [](unsigned char character) | |||||
| { | |||||
| return std::isspace(character) != 0; | |||||
| }); | |||||
| } | |||||
| ProjectOperationResult ProjectService::storageFailure( | |||||
| domain::ProjectStorageError error, const std::string &message) | |||||
| { | |||||
| return {false, ProjectServiceError::StorageFailure, error, message}; | |||||
| } | |||||
| } // namespace integrated_platform::services | |||||
| @@ -0,0 +1,80 @@ | |||||
| #pragma once | |||||
| #include "domain/project_storage.h" | |||||
| #include <string> | |||||
| namespace integrated_platform::services { | |||||
| // 工程管理操作失败原因 | |||||
| enum class ProjectServiceError | |||||
| { | |||||
| None, // 无错误 | |||||
| InvalidProjectName, // 工程名称为空或仅包含空白字符 | |||||
| FilePathRequired, // 未提供工程文件路径 | |||||
| InvalidProject, // 当前工程不符合领域规则 | |||||
| StorageFailure // 工程文件存储操作失败 | |||||
| }; | |||||
| // 工程管理操作结果 | |||||
| struct ProjectOperationResult | |||||
| { | |||||
| bool succeeded = false; // 操作是否成功 | |||||
| ProjectServiceError error = ProjectServiceError::None; // 服务层错误原因 | |||||
| domain::ProjectStorageError storageError = domain::ProjectStorageError::None; // 存储层错误原因 | |||||
| std::string message; // 面向调用方的结果描述 | |||||
| }; | |||||
| // 编排当前工程的新建、保存和加载流程 | |||||
| class ProjectService | |||||
| { | |||||
| public: | |||||
| // 使用外部提供的工程存储实现创建服务 | |||||
| explicit ProjectService(domain::ProjectStorage &storage); | |||||
| const domain::Project &project() const; | |||||
| domain::Project &editProject(); | |||||
| const std::string ¤tFilePath() const; | |||||
| bool hasCurrentFile() const; | |||||
| bool isModified() const; | |||||
| /** | |||||
| * @brief 创建未保存的新工程 | |||||
| * @param name 工程名称,不得为空或仅包含空白字符 | |||||
| * @return 新建操作结果 | |||||
| */ | |||||
| ProjectOperationResult createNewProject(const std::string &name); | |||||
| /** | |||||
| * @brief 保存到当前工程文件 | |||||
| * @return 保存操作结果,当前工程没有文件路径时返回失败 | |||||
| */ | |||||
| ProjectOperationResult save(); | |||||
| /** | |||||
| * @brief 将当前工程保存到指定文件 | |||||
| * @param file_path UTF-8 编码的目标文件路径,不得为空或仅包含空白字符 | |||||
| * @return 保存操作结果 | |||||
| */ | |||||
| ProjectOperationResult saveAs(const std::string &file_path); | |||||
| /** | |||||
| * @brief 从指定文件加载工程 | |||||
| * @param file_path UTF-8 编码的工程文件路径,不得为空或仅包含空白字符 | |||||
| * @return 加载操作结果,失败时不替换当前工程 | |||||
| */ | |||||
| ProjectOperationResult load(const std::string &file_path); | |||||
| private: | |||||
| static domain::Project makeNewProject(const std::string &name); | |||||
| static bool isBlank(const std::string &value); | |||||
| static ProjectOperationResult storageFailure( | |||||
| domain::ProjectStorageError error, const std::string &message); | |||||
| domain::ProjectStorage &storage_; // 非拥有的工程存储依赖 | |||||
| domain::Project project_; // 当前编辑中的工程 | |||||
| std::string current_file_path_; // 当前工程对应的文件路径 | |||||
| bool modified_ = true; // 当前工程是否有未保存修改 | |||||
| }; | |||||
| } // namespace integrated_platform::services | |||||
| @@ -0,0 +1,237 @@ | |||||
| #include "domain/project_storage.h" | |||||
| #include "infrastructure/json_project_storage.h" | |||||
| #include "services/project_service.h" | |||||
| #include <QFile> | |||||
| #include <QTemporaryDir> | |||||
| #include <exception> | |||||
| #include <iostream> | |||||
| #include <stdexcept> | |||||
| #include <string> | |||||
| namespace domain = integrated_platform::domain; | |||||
| namespace infrastructure = integrated_platform::infrastructure; | |||||
| namespace services = integrated_platform::services; | |||||
| namespace { | |||||
| void require(bool condition, const std::string &message) | |||||
| { | |||||
| if (!condition) | |||||
| { | |||||
| throw std::runtime_error(message); | |||||
| } | |||||
| } | |||||
| domain::Project makeExampleProject() | |||||
| { | |||||
| domain::HmiControl start_button; | |||||
| start_button.id = "start-button"; | |||||
| start_button.type = domain::HmiControlType::Button; | |||||
| start_button.bounds = {10, 20, 120, 48}; | |||||
| start_button.text = "Start"; | |||||
| start_button.binding = domain::RegisterAddress{domain::RegisterArea::M, 0}; | |||||
| start_button.properties.emplace("color", "green"); | |||||
| domain::HmiPage page; | |||||
| page.id = "main-page"; | |||||
| page.name = "Main"; | |||||
| page.controls.push_back(start_button); | |||||
| domain::LogicNode contact; | |||||
| contact.id = "start-contact"; | |||||
| contact.config = domain::ContactNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::M, 0}, | |||||
| domain::ContactMode::NormallyOpen}; | |||||
| domain::LogicNode compare; | |||||
| compare.id = "temperature-check"; | |||||
| compare.config = domain::CompareNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::D, 2}, | |||||
| domain::ComparisonOperator::GreaterThanOrEqual, | |||||
| static_cast<std::int16_t>(100)}; | |||||
| domain::LogicNode coil; | |||||
| coil.id = "run-coil"; | |||||
| coil.config = domain::CoilNodeConfig{ | |||||
| domain::RegisterAddress{domain::RegisterArea::M, 1}, | |||||
| domain::CoilMode::Set}; | |||||
| domain::ControlLogic logic; | |||||
| logic.id = "start-logic"; | |||||
| logic.name = "Start logic"; | |||||
| logic.enabled = false; | |||||
| logic.nodes = {contact, compare, coil}; | |||||
| logic.connections.push_back({contact.id, compare.id}); | |||||
| logic.connections.push_back({compare.id, coil.id}); | |||||
| domain::Project project; | |||||
| project.metadata = {"example-project", "Example project", "1.0"}; | |||||
| project.hmiPages.push_back(page); | |||||
| project.controlLogics.push_back(logic); | |||||
| return project; | |||||
| } | |||||
| void writeText(const QString &path, const QByteArray &content) | |||||
| { | |||||
| QFile file(path); | |||||
| require(file.open(QIODevice::WriteOnly), "test file must be writable"); | |||||
| require(file.write(content) == content.size(), "test file must be written completely"); | |||||
| } | |||||
| QByteArray readBytes(const QString &path) | |||||
| { | |||||
| QFile file(path); | |||||
| require(file.open(QIODevice::ReadOnly), "saved project must be readable"); | |||||
| return file.readAll(); | |||||
| } | |||||
| void testEmptyProjectRoundTrip() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| infrastructure::JsonProjectStorage storage; | |||||
| services::ProjectService service(storage); | |||||
| require(service.createNewProject("Empty project").succeeded, | |||||
| "empty project creation must succeed"); | |||||
| const QString path = directory.filePath("empty.json"); | |||||
| require(service.saveAs(path.toStdString()).succeeded, | |||||
| "empty project save must succeed"); | |||||
| require(!service.isModified(), "saved project must not be marked modified"); | |||||
| require(service.load(path.toStdString()).succeeded, | |||||
| "empty project load must succeed"); | |||||
| require(service.project().metadata.name == "Empty project", | |||||
| "empty project name must survive round trip"); | |||||
| require(service.project().hmiPages.empty(), "empty project must have no HMI pages"); | |||||
| require(service.project().controlLogics.empty(), | |||||
| "empty project must have no control logics"); | |||||
| } | |||||
| void testExampleProjectRoundTrip() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| infrastructure::JsonProjectStorage storage; | |||||
| services::ProjectService service(storage); | |||||
| service.editProject() = makeExampleProject(); | |||||
| const QString first_path = directory.filePath("example.json"); | |||||
| const QString second_path = directory.filePath("example-copy.json"); | |||||
| require(service.saveAs(first_path.toStdString()).succeeded, | |||||
| "example project save must succeed"); | |||||
| require(service.load(first_path.toStdString()).succeeded, | |||||
| "example project load must succeed"); | |||||
| const domain::Project &project = service.project(); | |||||
| require(project.metadata.id == "example-project", "project id must survive round trip"); | |||||
| require(project.hmiPages.size() == 1, "HMI page count must survive round trip"); | |||||
| require(project.hmiPages.front().controls.front().binding->area() | |||||
| == domain::RegisterArea::M, | |||||
| "HMI M binding must survive round trip"); | |||||
| require(project.hmiPages.front().controls.front().properties.at("color") == "green", | |||||
| "HMI properties must survive round trip"); | |||||
| require(project.controlLogics.size() == 1, | |||||
| "control logic count must survive round trip"); | |||||
| require(!project.controlLogics.front().enabled, | |||||
| "control logic enabled state must survive round trip"); | |||||
| require(project.controlLogics.front().nodes.size() == 3, | |||||
| "logic node count must survive round trip"); | |||||
| const auto &compare = std::get<domain::CompareNodeConfig>( | |||||
| project.controlLogics.front().nodes.at(1).config); | |||||
| require(compare.address.index() == 2 && compare.value == 100, | |||||
| "comparison configuration must survive round trip"); | |||||
| require(service.saveAs(second_path.toStdString()).succeeded, | |||||
| "save as must succeed after load"); | |||||
| require(readBytes(first_path) == readBytes(second_path), | |||||
| "save and save as must produce stable JSON"); | |||||
| } | |||||
| void testInvalidFiles() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| infrastructure::JsonProjectStorage storage; | |||||
| services::ProjectService service(storage); | |||||
| service.editProject().metadata.name = "Current project"; | |||||
| const QString invalid_json = directory.filePath("invalid-json.json"); | |||||
| const QString missing_field = directory.filePath("missing-field.json"); | |||||
| const QString unsupported_version = directory.filePath("unsupported-version.json"); | |||||
| writeText(invalid_json, "{"); | |||||
| auto result = service.load(invalid_json.toStdString()); | |||||
| require(!result.succeeded | |||||
| && result.storageError == domain::ProjectStorageError::InvalidJson, | |||||
| "invalid JSON must be rejected"); | |||||
| require(service.project().metadata.name == "Current project", | |||||
| "invalid load must keep current project"); | |||||
| writeText(missing_field, R"({"formatVersion":"1.0"})"); | |||||
| result = service.load(missing_field.toStdString()); | |||||
| require(!result.succeeded | |||||
| && result.storageError == domain::ProjectStorageError::MissingField, | |||||
| "missing fields must be rejected"); | |||||
| writeText(unsupported_version, R"({"formatVersion":"2.0"})"); | |||||
| result = service.load(unsupported_version.toStdString()); | |||||
| require(!result.succeeded | |||||
| && result.storageError == domain::ProjectStorageError::UnsupportedVersion, | |||||
| "unsupported versions must be rejected"); | |||||
| } | |||||
| void testServiceStateAndSaveErrors() | |||||
| { | |||||
| QTemporaryDir directory; | |||||
| require(directory.isValid(), "temporary directory must be valid"); | |||||
| infrastructure::JsonProjectStorage storage; | |||||
| services::ProjectService service(storage); | |||||
| require(service.save().error == services::ProjectServiceError::FilePathRequired, | |||||
| "save without a current path must be rejected"); | |||||
| require(service.createNewProject(" ").error | |||||
| == services::ProjectServiceError::InvalidProjectName, | |||||
| "blank project names must be rejected"); | |||||
| const QString path = directory.filePath("state.json"); | |||||
| service.editProject().metadata.name = "State project"; | |||||
| require(service.saveAs(path.toStdString()).succeeded, | |||||
| "state project save must succeed"); | |||||
| service.editProject().metadata.name = "Changed project"; | |||||
| require(service.isModified(), "editing the project must mark it modified"); | |||||
| require(service.save().succeeded, "save must use the current file path"); | |||||
| require(!service.isModified(), "successful save must clear modified state"); | |||||
| const QString failed_path = directory.filePath("missing/subdir/state.json"); | |||||
| require(!service.saveAs(failed_path.toStdString()).succeeded, | |||||
| "save to an unavailable path must fail"); | |||||
| require(service.currentFilePath() == path.toStdString(), | |||||
| "failed save as must keep the previous current path"); | |||||
| } | |||||
| } // namespace | |||||
| int main() | |||||
| { | |||||
| try | |||||
| { | |||||
| testEmptyProjectRoundTrip(); | |||||
| testExampleProjectRoundTrip(); | |||||
| testInvalidFiles(); | |||||
| testServiceStateAndSaveErrors(); | |||||
| } | |||||
| catch (const std::exception &error) | |||||
| { | |||||
| std::cerr << "project management tests failed: " << error.what() << '\n'; | |||||
| return 1; | |||||
| } | |||||
| std::cout << "project management tests passed\n"; | |||||
| return 0; | |||||
| } | |||||
| @@ -0,0 +1,31 @@ | |||||
| TEMPLATE = app | |||||
| TARGET = project_management_tests | |||||
| CONFIG += console c++17 testcase warn_on | |||||
| CONFIG -= app_bundle | |||||
| QT += core | |||||
| INCLUDEPATH += ../src | |||||
| SOURCES += \ | |||||
| project_management_tests.cpp \ | |||||
| ../src/domain/register_address.cpp \ | |||||
| ../src/domain/register_repository.cpp \ | |||||
| ../src/domain/hmi_model.cpp \ | |||||
| ../src/domain/control_logic_model.cpp \ | |||||
| ../src/domain/project_model.cpp \ | |||||
| ../src/domain/runtime_state.cpp \ | |||||
| ../src/services/project_service.cpp \ | |||||
| ../src/infrastructure/json_project_storage.cpp | |||||
| HEADERS += \ | |||||
| ../src/domain/register_address.h \ | |||||
| ../src/domain/register_repository.h \ | |||||
| ../src/domain/hmi_model.h \ | |||||
| ../src/domain/control_logic_model.h \ | |||||
| ../src/domain/project_model.h \ | |||||
| ../src/domain/runtime_state.h \ | |||||
| ../src/domain/project_storage.h \ | |||||
| ../src/services/project_service.h \ | |||||
| ../src/infrastructure/json_project_storage.h | |||||
| @@ -1,8 +1,8 @@ | |||||
| # Current Handoff | # Current Handoff | ||||
| - Goal: 完成综合平台编程器的核心数据模型与接口。 | |||||
| - Goal: 完成综合平台编程器的工程管理和后续编辑功能。 | |||||
| - Branch: `main`。 | - Branch: `main`。 | ||||
| - Current status: 已完成开发顺序第 3 步,核心领域模型、寄存器仓库接口和运行模式边界已定义。 | |||||
| - Current status: 已完成开发顺序第 4 步,工程管理服务、版本化 JSON 存储和核心往返测试已完成。 | |||||
| - Changed files: | - Changed files: | ||||
| - `app/integrated_platform.pro` | - `app/integrated_platform.pro` | ||||
| - `app/src/main.cpp` | - `app/src/main.cpp` | ||||
| @@ -29,8 +29,15 @@ | |||||
| - `app/src/domain/project_model.cpp` | - `app/src/domain/project_model.cpp` | ||||
| - `app/src/domain/runtime_state.h` | - `app/src/domain/runtime_state.h` | ||||
| - `app/src/domain/runtime_state.cpp` | - `app/src/domain/runtime_state.cpp` | ||||
| - `app/src/domain/project_storage.h` | |||||
| - `app/src/services/project_service.h` | |||||
| - `app/src/services/project_service.cpp` | |||||
| - `app/src/infrastructure/json_project_storage.h` | |||||
| - `app/src/infrastructure/json_project_storage.cpp` | |||||
| - `app/tests/domain_tests.pro` | - `app/tests/domain_tests.pro` | ||||
| - `app/tests/domain_tests.cpp` | - `app/tests/domain_tests.cpp` | ||||
| - `app/tests/project_management_tests.pro` | |||||
| - `app/tests/project_management_tests.cpp` | |||||
| - Decisions made: | - Decisions made: | ||||
| - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | - Qt 应用源码位于 `app/`,构建输出位于被忽略的 `build/`。 | ||||
| - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | - 使用 qmake、Qt Widgets、Qt SerialBus 和 MinGW 8.1。 | ||||
| @@ -40,6 +47,9 @@ | |||||
| - 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。 | - 领域模型仅使用 C++17 标准库,不依赖 Qt UI、串口或 Modbus。 | ||||
| - M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。 | - M 地址只承载布尔值,D 地址只承载带符号 16 位值,地址范围统一为 `0~4000`。 | ||||
| - `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。 | - `RegisterRepository` 统一虚拟寄存器和真实 PLC 缓存的访问契约。 | ||||
| - `ProjectStorage` 是领域层的工程文件存储契约,具体 JSON 和 Qt 文件 API 位于基础设施层。 | |||||
| - `ProjectService` 负责工程路径、修改状态和新建保存加载流程;加载成功后才替换当前工程。 | |||||
| - 工程文件格式版本当前为 `1.0`,保存使用 `QSaveFile` 原子提交。 | |||||
| - 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。 | - 控制逻辑节点使用 `std::variant` 组合触点、线圈和数值比较的独立配置类型。 | ||||
| - 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。 | - 当前控制逻辑范围不包含定时器;不得因为设备手册或旧开发计划提前加入未确认功能。 | ||||
| - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 | - 新增逻辑节点时增加独立配置类型,不向通用节点结构堆叠无关可选字段。 | ||||
| @@ -54,6 +64,8 @@ | |||||
| - 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。 | - 在 `build/core-model/` 重新执行 qmake 和 `mingw32-make -j2`,应用构建成功。 | ||||
| - 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。 | - 在 `build/domain-tests/` 构建并运行领域测试,输出 `domain tests passed`。 | ||||
| - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 | - 控制逻辑节点配置重构后再次构建应用和领域测试,均通过。 | ||||
| - Remaining work: 进入开发顺序第 4 步,实现工程新建、保存、另存为和加载。 | |||||
| - 在 `build/project-management-tests/` 构建并运行工程管理测试,输出 `project management tests passed`。 | |||||
| - 工程管理加入应用后重新执行 Qt 应用构建,构建成功。 | |||||
| - Remaining work: 进入开发顺序第 5 步,完成主界面和状态管理。 | |||||
| - Known risks / blockers: 无。 | - Known risks / blockers: 无。 | ||||
| - Suggested next command: 基于 `Project` 领域模型设计版本化的工程文件 DTO 与存储接口。 | |||||
| - Suggested next command: 在主窗口注入 `ProjectService`,接入新建、打开、保存和另存为操作。 | |||||
| @@ -56,11 +56,16 @@ main.cpp -> UI + Services + Infrastructure | |||||
| | --- | --- | | | --- | --- | | ||||
| | `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 | | | `register_address.*` | 定义 M/D 地址值对象,统一校验项目地址范围 `0~4000`。 | | ||||
| | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | | `register_repository.*` | 定义 M 位和 D 字的统一读写接口,并提供离线内存实现。 | | ||||
| | `project_storage.*` | 定义工程文件保存和加载的领域存储契约,不依赖具体文件格式。 | | |||||
| | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 | | | `hmi_model.*` | 定义 HMI 页面、控件、位置、扩展属性和寄存器绑定。 | | ||||
| | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | | `control_logic_model.*` | 使用独立配置类型定义触点、线圈和数值比较节点,并校验地址区域与连接引用。 | | ||||
| | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | | `project_model.*` | 聚合工程元数据、HMI 页面和控制逻辑,并校验工程内标识唯一性。 | | ||||
| | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | | `runtime_state.*` | 定义编辑态、离线运行态、真机运行态及合法切换规则。 | | ||||
| 工程管理由 `services/ProjectService` 编排新建、保存、另存为和加载。JSON 文件转换和原子写入 | |||||
| 由 `infrastructure/JsonProjectStorage` 实现。加载必须先完成文件解析、版本检查和领域校验,成功 | |||||
| 后才能替换当前工程;保存失败或加载失败不得修改当前工程路径和内存内容。 | |||||
| `RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用 | `RegisterRepository` 是 HMI、逻辑执行器和运行服务唯一可见的寄存器边界。离线运行使用 | ||||
| `VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是 | `VirtualRegisterRepository`;真机运行后续注入由基础设施层实现的 PLC 缓存仓库。仓库读取的是 | ||||
| 最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。 | 最近一次有效值,写入成功只表示请求被仓库接受;真实 PLC 的异步确认由服务层负责反馈。 | ||||
| @@ -30,6 +30,7 @@ | |||||
| - 实现新建、保存、另存为和加载工程。 | - 实现新建、保存、另存为和加载工程。 | ||||
| - 保存 HMI 配置、控制逻辑、M/D 绑定和必要的工程版本信息。 | - 保存 HMI 配置、控制逻辑、M/D 绑定和必要的工程版本信息。 | ||||
| - 使用服务层编排工程操作,基础设施层负责版本化 JSON 转换和原子文件写入。 | |||||
| - 对无效文件、缺失字段和不兼容版本给出明确错误。 | - 对无效文件、缺失字段和不兼容版本给出明确错误。 | ||||
| 完成标准:空工程和示例工程可以保存后重新加载,数据保持一致。 | 完成标准:空工程和示例工程可以保存后重新加载,数据保持一致。 | ||||