From 21eacf2f264a128d582699ced08487d6845121b9 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Thu, 6 Aug 2026 17:41:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 32 +- README.md | 402 +++++++++--------------- backend/CMakeLists.txt | 24 +- backend/include/adminive/amis.hpp | 36 ++- backend/include/adminive/descriptor.hpp | 108 ++++++- backend/include/adminive/http.hpp | 111 ++++++- backend/include/adminive/json.hpp | 15 +- backend/src/config_store.cpp | 84 +++++ backend/src/config_store.hpp | 25 ++ backend/src/device_tables.cpp | 139 ++++++++ backend/src/device_tables.hpp | 53 ++++ backend/src/example.cpp | 57 ++++ backend/src/example.hpp | 186 +++-------- backend/src/example_descriptors.hpp | 156 +++++++++ backend/src/main.cpp | 123 +++----- backend/src/server_app.cpp | 75 +++++ backend/src/server_app.hpp | 26 ++ backend/tests/test.cpp | 212 ++++--------- cmake/PackageZip.cmake | 35 ++- 19 files changed, 1213 insertions(+), 686 deletions(-) create mode 100644 backend/src/config_store.cpp create mode 100644 backend/src/config_store.hpp create mode 100644 backend/src/device_tables.cpp create mode 100644 backend/src/device_tables.hpp create mode 100644 backend/src/example.cpp create mode 100644 backend/src/example_descriptors.hpp create mode 100644 backend/src/server_app.cpp create mode 100644 backend/src/server_app.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 05ee9c6..1665a70 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,17 +2,27 @@ cmake_minimum_required(VERSION 3.20) project(Adminive VERSION 1.0.0 LANGUAGES CXX) include(CTest) add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/backend") +set(adminive_package_includes + "/.gitignore" + "/backend/" + "/cmake/" + "/config/" + "/CMakeLists.txt" + "/frontend/" + "/README.md" + "/scripts/" +) set(adminive_package_excludes - "/.git/" - "/.idea/" - "/build/" - "/cmake-build-*/" - "/doc/" - "/node_modules/" - "/frontend/frontend.zip" - "/frontend_dist/" - "*.tsbuildinfo" - "*.zip" + "/.git/" + "/.idea/" + "/build/" + "/cmake-build-*/" + "/doc/" + "/node_modules/" + "/frontend/frontend.zip" + "/frontend_dist/" + "*.tsbuildinfo" + "*.zip" ) include("${CMAKE_CURRENT_LIST_DIR}/cmake/PackageZip.cmake") -add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" adminive_package_excludes) +add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" "${CMAKE_CURRENT_LIST_DIR}" adminive_package_includes adminive_package_excludes) diff --git a/README.md b/README.md index 91acc80..893a9d6 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,10 @@ -# Adminive Backend-Driven Complete Example +# Adminive Backend-Driven Example -Adminive 使用 C++20 模板、Concepts、成员指针和 `magic_enum` 描述业务类型。同一份后端描述驱动 JSON 双向转换、事务式赋值、字段权限、校验、amis CRUD、枚举列表列、树状配置、联动条件、日期控件、颜色控件和一次性提交。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段或业务判断。 +Adminive 使用 C++20 模板、成员指针和 `magic_enum` 生成后端描述。前端只负责渲染 `/admin/amis` 返回的 amis Schema,不包含业务字段、枚举映射、列顺序、排序规则或配置联动。 -## 目录结构 +## 数据名称与界面名称 -```text -Adminive/ -├── backend/ -│ ├── include/adminive/ -│ ├── src/ -│ │ ├── example.hpp -│ │ └── main.cpp -│ ├── tests/ -│ └── third_party/ -├── cmake/ -├── frontend/ -├── scripts/ -├── CMakeLists.txt -└── README.md -``` - -`example.hpp` 位于 `backend/src`,只作为完整示例实现,不属于公共库头文件。 - -## 列字段名称、显示名称和排序 - -```cpp -ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") - .creatable() - .editable() - .required() - .list_label("Mode") - .order(5) - .sortable(); -``` - -- `name` 来自成员名称,用于 JSON 字段和接口参数。 -- `label` 用于表单标签。 -- `list_label` 用于列表表头,未设置时回退到 `label`。 -- `order` 控制列表列顺序,未设置时按字段声明顺序生成默认值。 -- `sortable` 控制该列能否排序,后端只接受已声明为可排序的字段。 - -## 枚举列表项 - -```cpp -enum class Radio_Mode { - receive, - transmit, - duplex, - maintenance -}; -``` - -业务结构直接使用枚举: +字段成员名继续作为稳定的数据名称: ```cpp struct Radio_State { @@ -59,260 +12,179 @@ struct Radio_State { }; ``` -描述中不需要手写选项: +界面标签、列表表头和枚举显示名称在描述端单独配置: ```cpp -ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") - .creatable() - .editable() - .required() - .list_label("Mode") - .order(5) - .sortable(); +ADMINIVE_FIELD_LABEL(T, mode, "运行模式") + .list_label("模式") + .sortable() + .enum_label("接收") + .enum_label("发送") + .enum_label("双工") + .enum_label("维护"); ``` -后端通过 `magic_enum::enum_values` 和 `magic_enum::enum_name` 自动生成: +接口仍返回: ```json { - "name": "mode", - "value_type": "enum", - "options": [ - {"label": "Receive", "value": "receive"}, - {"label": "Transmit", "value": "transmit"}, - {"label": "Duplex", "value": "duplex"}, - {"label": "Maintenance", "value": "maintenance"} - ] + "mode": "duplex" } ``` -表格列使用 amis `mapping`,接口数据仍然返回稳定的枚举字符串,例如 `"duplex"`。 +表格显示为“模式:双工”。`magic_enum` 负责枚举值发现和稳定字符串生成,`.enum_label` 只覆盖界面文字。 -## 树状配置 +## 列顺序与用户排序 -树结构由嵌套的已描述对象表达,不额外维护一份前端树配置: +字段的默认列顺序和是否允许按值排序由后端描述: + +```cpp +ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量") + .list_label("缓冲区") + .order(10) + .sortable(); +``` + +对象描述可以定义默认排序: + +```cpp +object(/* fields */) + .default_sort("buffer_count"); +``` + +用户点击可排序表头时,前端发送 `orderBy` 和 `orderDir`,后端只接受声明为 `.sortable()` 的字段。 + +用户也可以拖拽调整行顺序: + +```cpp +object(/* fields */) + .user_reorderable(); +``` + +该配置会生成 `draggable` 和 `saveOrderApi`,后端提供 `POST /order` 保存当前运行期顺序。列选择菜单支持拖拽调整列先后,默认由 `.column_reorderable()` 控制。 + +## CRUD 中文文字 + +CRUD 固定文字也在对象描述中配置: + +```cpp +object(/* fields */) + .label("无线电状态") + .id_label("编号") + .create_label("新增") + .edit_label("编辑") + .delete_label("删除") + .actions_label("操作") + .confirm_label("确认修改") + .view_status_label("查看状态 ▾") + .management_label("无线电状态列表"); +``` + +## 树状配置与持久化 + +嵌套 C++ 对象直接表达树状配置: ```cpp -struct Endpoint_Config { - bool enabled{true}; - std::string host{"127.0.0.1"}; - Range_Value port{9000}; -}; -struct Appearance_Config { - std::string panel_title{"Radio Control"}; - std::string effective_date{"2026-08-06"}; - std::string accent_color{"#2563eb"}; -}; struct Radio_Service_Config { std::string profile_name{"Primary Radio Profile"}; Radio_Mode mode{Radio_Mode::receive}; - Range_Value worker_count{4}; - double receive_gain{1.25}; Endpoint_Config primary_endpoint{}; Endpoint_Config backup_endpoint{}; Appearance_Config appearance{}; }; ``` -界面结构为: - -```text -Radio Service Configuration -├── Profile Name -├── Operating Mode -├── Worker Count -├── Receive Gain -├── Primary Endpoint -│ ├── Enable Endpoint -│ ├── Host Address -│ └── Port -├── Backup Endpoint -│ ├── Enable Endpoint -│ ├── Host Address -│ └── Port -└── Appearance - ├── Panel Title - ├── Effective Date - └── Accent Color -``` - -嵌套对象在 amis 表单中渲染成可折叠 `fieldset`,子字段使用点路径,例如: - -```text -primary_endpoint.host -appearance.effective_date -``` - -提交时仍然形成嵌套 JSON 对象。 - -## 配置联动 - -字段描述通过 `visible_on` 保存 amis 表达式: +嵌套对象在界面中生成 `fieldset`。字符串、整数、浮点数、布尔、枚举、日期和颜色分别生成对应控件。联动条件仍由后端定义: ```cpp -ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint") +ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点") .editable() .visible_on("${mode == 'duplex'}"); ``` -当 `mode` 不是 `duplex` 时,整个 `Backup Endpoint` 子树隐藏。 - -第二个配置示例使用组合条件: - -```cpp -ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint") - .editable() - .visible_on("${enabled && channel == 'webhook'}"); -``` - -只有启用告警且投递通道为 `webhook` 时,才显示 webhook 子配置。 - -## 完整输入控件 - -后端根据 C++ 类型自动选择基础控件: +配置文件位于: ```text -std::string -> input-text -整数类型 -> input-number -浮点类型 -> input-number -bool -> switch -enum class -> select +config/adminive.json ``` -日期和颜色通过后端字段描述指定: - -```cpp -ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date") - .editable() - .required() - .widget("input-date"); -ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color") - .editable() - .required() - .widget("input-color"); -``` - -日期控件自动补充: - -```json -{ - "type": "input-date", - "valueFormat": "YYYY-MM-DD", - "displayFormat": "YYYY-MM-DD", - "clearable": true -} -``` - -## 一次性确认修改 - -配置表单不会在单个输入项改变时调用后端。所有修改先保留在表单数据域,点击 `Confirm Changes` 后一次性提交完整配置: - -```json -{ - "actions": [ - {"type": "reset", "label": "Reset"}, - {"type": "submit", "label": "Confirm Changes", "level": "primary"} - ] -} -``` - -后端先复制当前对象,在副本上完成所有字段赋值和校验,全部成功后才替换原对象,因此一次提交具有对象级事务语义。 - -## 两组配置示例 - -页面包含三个后端定义的标签页: +文件保存以下持久化内容: ```text -Radio State List -Radio Service Configuration -Alert Configuration +Application_Config +├── default_device_type +├── radio_service +├── alerts +├── serial_table +└── network_table ``` -`Radio Service Configuration` 展示字符串、枚举、整数、浮点数、树状子配置、日期和颜色。 +程序启动时读取该文件。文件不存在时,使用 C++ 默认值自动创建。配置表单点击“确认修改”后,后端先在副本上完成赋值和校验,再写入临时文件并替换正式配置文件,成功后才替换内存对象。 -`Alert Configuration` 展示布尔开关、字符串、两个枚举、数字、日期、颜色和条件显示的 webhook 子配置。 +## 多态类型表格 -## 对象状态 - -对象状态使用独立返回类型描述,不混入 CRUD 对象字段: +不同表格类型通过 C++ 虚函数注册: ```cpp -struct Radio_Item_Status { - Status_Value operating_state; - Status_Value refresh_sequence; - Status_Value refresh_time; +class Device_Table_Base { +public: + virtual ~Device_Table_Base() = default; + virtual Device_Table_Type type() const noexcept = 0; + virtual std::string_view type_name() const noexcept = 0; + virtual std::string_view type_label() const noexcept = 0; + virtual Json amis_schema() const = 0; + virtual void bind(httplib::Server& server) = 0; }; ``` -状态值同时携带颜色: +示例实现两个子类: -```json -{ - "operating_state": { - "value": "running", - "color": "#16a34a" - } -} +```text +Serial_Device_Table +Network_Device_Table ``` -资源通过成员函数指针注册: +界面切换 `device_type` 后,`service.schemaApi` 重新向后端请求对应子类的 Schema: + +```text +serial -> 串口、波特率、校验位、启用 +network -> 地址、协议、超时、TLS +``` + +因此切换类型后,表格列、编辑表单、配置表单和接口路径都会一起改变,前端不需要识别任何业务类型。 + +## 对象状态 + +对象状态使用独立类型,并通过成员函数指针注册: ```cpp resource.register_status<&Radio_State::status>(2000); ``` -只有用户打开某一行的 `View status` PopOver 后,该行状态才开始请求和刷新。 +只有用户打开某一行的“查看状态”弹层后,该行状态才开始请求和刷新。颜色由后端状态值返回。 -## API +## 主要接口 ```text GET /admin/amis GET /admin/status -GET /admin/radio_states/descriptor -GET /admin/radio_states/status/descriptor -GET /admin/radio_states/amis GET /admin/radio_states -GET /admin/radio_states/{id} -GET /admin/radio_states/{id}/status POST /admin/radio_states PUT /admin/radio_states/{id} -PATCH /admin/radio_states/{id} DELETE /admin/radio_states/{id} -GET /admin/config/radio/descriptor -GET /admin/config/radio/data -GET /admin/config/radio/amis +GET /admin/radio_states/{id}/status +POST /admin/device_tables/serial/items/order +POST /admin/device_tables/network/items/order +GET /admin/device_tables/schema?type=serial +GET /admin/device_tables/schema?type=network +POST /admin/device_tables/default POST /admin/config/radio/data -GET /admin/config/alerts/descriptor -GET /admin/config/alerts/data -GET /admin/config/alerts/amis POST /admin/config/alerts/data +POST /admin/device_tables/serial/config/data +POST /admin/device_tables/network/config/data ``` -## 前端 - -创建外部 `node_modules` 相对符号链接: - -```powershell -pwsh -NoProfile -File .\scripts\link_node_modules.ps1 -``` - -默认结构: - -```text -Adminive/ -├── frontend/ -│ └── node_modules -> ../node_modules -└── node_modules/ -``` - -安装和构建: - -```powershell -npm --prefix .\frontend ci -npm --prefix .\frontend run build -``` - -## 后端 +## 构建 ```powershell cmake -S . -B build -G Ninja @@ -321,20 +193,46 @@ ctest --test-dir build --output-on-failure .\build\backend\Adminive_Server.exe 9999 ``` -访问: - -```text -http://127.0.0.1:9999 -``` - ## 源码压缩目标 -```powershell -cmake --build build --target package_zip -``` - -输出文件位置由第二个参数明确指定,第三个参数传入排除列表变量名: +压缩函数参数顺序: ```cmake -add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" adminive_package_excludes) +add_project_zip_target( + target_name + output_file + root_directory + include_list_name + exclude_list_name +) ``` + +调用示例: + +```cmake +set(adminive_package_includes + "/backend/" + "/cmake/" + "/config/" + "/frontend/" + "/scripts/" + "/CMakeLists.txt" + "/README.md" +) +set(adminive_package_excludes + "/.git/" + "/.idea/" + "/build/" + "/cmake-build-*/" + "/doc/" + "/node_modules/" + "/frontend/frontend.zip" + "/frontend_dist/" + "*.tsbuildinfo" + "*.zip" +) +include("${CMAKE_CURRENT_LIST_DIR}/cmake/PackageZip.cmake") +add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" "${CMAKE_CURRENT_LIST_DIR}" adminive_package_includes adminive_package_excludes) +``` + +函数只扫描包含列表指定的目录和文件,然后相对于根目录应用排除列表。 diff --git a/backend/CMakeLists.txt b/backend/CMakeLists.txt index 1a2dd48..f073eab 100644 --- a/backend/CMakeLists.txt +++ b/backend/CMakeLists.txt @@ -5,7 +5,9 @@ set(adminive_backend_root "${CMAKE_CURRENT_LIST_DIR}") set(adminive_include_dir "${adminive_backend_root}/include") set(adminive_third_party_include_dir "${adminive_backend_root}/third_party/include") set(adminive_frontend_dist_dir "${adminive_backend_root}/../frontend_dist") +set(adminive_config_file "${adminive_backend_root}/../config/adminive.json") file(TO_CMAKE_PATH "${adminive_frontend_dist_dir}" adminive_frontend_dist_dir) +file(TO_CMAKE_PATH "${adminive_config_file}" adminive_config_file) add_library(Adminive INTERFACE) add_library(Adminive::Adminive ALIAS Adminive) target_include_directories(Adminive INTERFACE @@ -20,11 +22,27 @@ if(WIN32) ) target_link_libraries(Adminive INTERFACE ws2_32) endif() -add_executable(Adminive_Server "${adminive_backend_root}/src/main.cpp") -target_compile_definitions(Adminive_Server PRIVATE ADMINIVE_FRONTEND_DIST_DIR="${adminive_frontend_dist_dir}") +set(adminive_server_sources + "${adminive_backend_root}/src/config_store.cpp" + "${adminive_backend_root}/src/device_tables.cpp" + "${adminive_backend_root}/src/example.cpp" + "${adminive_backend_root}/src/main.cpp" + "${adminive_backend_root}/src/server_app.cpp" +) +add_executable(Adminive_Server ${adminive_server_sources}) +target_include_directories(Adminive_Server PRIVATE "${adminive_backend_root}/src") +target_compile_definitions(Adminive_Server PRIVATE + ADMINIVE_FRONTEND_DIST_DIR="${adminive_frontend_dist_dir}" + ADMINIVE_CONFIG_FILE="${adminive_config_file}" +) target_link_libraries(Adminive_Server PRIVATE Adminive::Adminive) if(BUILD_TESTING) - add_executable(Adminive_Test "${adminive_backend_root}/tests/test.cpp") + add_executable(Adminive_Test + "${adminive_backend_root}/src/config_store.cpp" + "${adminive_backend_root}/src/device_tables.cpp" + "${adminive_backend_root}/src/example.cpp" + "${adminive_backend_root}/tests/test.cpp" + ) target_include_directories(Adminive_Test PRIVATE "${adminive_backend_root}/src") target_link_libraries(Adminive_Test PRIVATE Adminive::Adminive) add_test(NAME Adminive_Test COMMAND Adminive_Test) diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index dba397f..4e1b2b6 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -138,9 +138,9 @@ inline Json make_amis_object_status_body(const Json& descriptor) { inline Json make_amis_object_status_service(const Json& descriptor, std::string status_api, std::uint64_t interval) { return Json{{"type", "service"}, {"api", Json{{"method", "get"}, {"url", std::move(status_api)}}}, {"initFetch", true}, {"interval", interval}, {"silentPolling", true}, {"showErrorMsg", true}, {"body", make_amis_object_status_body(descriptor)}}; } -inline Json make_amis_object_status_column(const Json& descriptor, std::string status_api, std::uint64_t interval) { +inline Json make_amis_object_status_column(const Json& descriptor, std::string status_api, std::uint64_t interval, std::string action_label) { const Json pop_over{{"trigger", "click"}, {"position", "left-top"}, {"showIcon", false}, {"body", make_amis_object_status_service(descriptor, std::move(status_api), interval)}}; - return Json{{"type", "tpl"}, {"label", descriptor.at("label")}, {"tpl", R"(View status ▾)"}, {"popOver", pop_over}}; + return Json{{"type", "tpl"}, {"label", descriptor.at("label")}, {"tpl", "" + std::move(action_label) + ""}, {"popOver", pop_over}}; } template Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { @@ -154,8 +154,9 @@ Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::strin template Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = nullptr, std::uint64_t status_interval = 2000) { const Json descriptor = to_descriptor_json(); + const Json& list = descriptor.at("list"); const std::string crud_id = descriptor.at("name").get() + "_crud"; - Json columns = Json::array({Json{{"name", "id"}, {"label", "Id"}}}); + Json columns = Json::array({Json{{"name", "id"}, {"label", list.at("id_label")}, {"sortable", true}}}); std::vector list_fields; for(const auto& field : descriptor.at("fields")) { if(field.at("readable").get() && field.at("list_visible").get() && field.at("value_type") != "object") { @@ -169,17 +170,28 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n columns.push_back(make_amis_column(field)); } if(status_descriptor) { - columns.push_back(make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval)); + columns.push_back(make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval, list.at("view_status_label").get())); } - const Json create_form{{"type", "form"}, {"api", Json{{"method", "post"}, {"url", base_api}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "creatable")}, {"actions", make_amis_form_actions("Create")}}; - const Json edit_form{{"type", "form"}, {"api", Json{{"method", "put"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "editable")}, {"actions", make_amis_form_actions("Confirm Changes")}}; - const Json create_button{{"type", "button"}, {"label", "Create"}, {"level", "primary"}, {"actionType", "dialog"}, {"dialog", Json{{"title", "Create " + descriptor.at("label").get()}, {"body", create_form}}}}; + const std::string create_label = list.at("create_label").get(); + const std::string edit_label = list.at("edit_label").get(); + const Json create_form{{"type", "form"}, {"api", Json{{"method", "post"}, {"url", base_api}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "creatable")}, {"actions", make_amis_form_actions(create_label)}}; + const Json edit_form{{"type", "form"}, {"api", Json{{"method", "put"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "editable")}, {"actions", make_amis_form_actions(list.at("confirm_label").get())}}; + const Json create_button{{"type", "button"}, {"label", create_label}, {"level", "primary"}, {"actionType", "dialog"}, {"dialog", Json{{"title", create_label + " " + descriptor.at("label").get()}, {"body", create_form}}}}; Json actions = Json::array(); - actions.push_back(Json{{"type", "button"}, {"label", "Edit"}, {"level", "link"}, {"actionType", "dialog"}, {"dialog", Json{{"title", "Edit " + descriptor.at("label").get()}, {"body", edit_form}}}}); - actions.push_back(Json{{"type", "button"}, {"label", "Delete"}, {"level", "link"}, {"className", "text-danger"}, {"actionType", "ajax"}, {"confirmText", "Delete this record?"}, {"api", Json{{"method", "delete"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}}); - columns.push_back(Json{{"type", "operation"}, {"label", "Actions"}, {"buttons", std::move(actions)}}); - Json crud{{"type", "crud"}, {"id", crud_id}, {"api", Json{{"method", "get"}, {"url", base_api}}}, {"syncLocation", false}, {"headerToolbar", Json::array({"reload", create_button})}, {"columns", std::move(columns)}}; - return Json{{"type", "page"}, {"title", descriptor.at("label").get() + " Management"}, {"body", std::move(crud)}}; + actions.push_back(Json{{"type", "button"}, {"label", edit_label}, {"level", "link"}, {"actionType", "dialog"}, {"dialog", Json{{"title", edit_label + " " + descriptor.at("label").get()}, {"body", edit_form}}}}); + actions.push_back(Json{{"type", "button"}, {"label", list.at("delete_label")}, {"level", "link"}, {"className", "text-danger"}, {"actionType", "ajax"}, {"confirmText", list.at("delete_confirm")}, {"api", Json{{"method", "delete"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}}); + columns.push_back(Json{{"type", "operation"}, {"label", list.at("actions_label")}, {"buttons", std::move(actions)}}); + const Json columns_toggler{{"type", "columns-toggler"}, {"draggable", list.at("column_reorderable")}}; + Json crud{{"type", "crud"}, {"id", crud_id}, {"api", Json{{"method", "get"}, {"url", base_api}}}, {"syncLocation", false}, {"primaryField", "id"}, {"headerToolbar", Json::array({columns_toggler, "reload", create_button})}, {"columns", std::move(columns)}}; + const std::string default_order_by = list.at("default_order_by").get(); + if(!default_order_by.empty()) { + crud["defaultParams"] = Json{{"orderBy", default_order_by}, {"orderDir", list.at("default_order_dir")}}; + } + if(list.at("user_reorderable").get()) { + crud["draggable"] = true; + crud["saveOrderApi"] = Json{{"method", "post"}, {"url", base_api + "/order"}}; + } + return Json{{"type", "page"}, {"title", list.at("management_label")}, {"body", std::move(crud)}}; } template Json to_amis_crud_status_schema(std::string base_api, std::string status_api, const Json* object_status_descriptor = nullptr, std::uint64_t object_status_interval = 2000) { diff --git a/backend/include/adminive/descriptor.hpp b/backend/include/adminive/descriptor.hpp index 2127638..1b894e8 100644 --- a/backend/include/adminive/descriptor.hpp +++ b/backend/include/adminive/descriptor.hpp @@ -1,7 +1,10 @@ #pragma once #include "adminive/concepts.hpp" +#include "magic_enum/magic_enum.hpp" #include #include +#include +#include #include #include #include @@ -110,6 +113,17 @@ public: result.sortable_ = value; return result; } + template + requires std::is_enum_v && std::same_as + Field_Descriptor enum_label(std::string label) const { + const auto name = magic_enum::enum_name(Value); + if(name.empty()) { + throw std::invalid_argument("enum value is outside the magic_enum reflection range"); + } + auto result = *this; + result.enum_labels_.insert_or_assign(std::string(name), std::move(label)); + return result; + } const std::string& name() const noexcept { return name_; } @@ -128,6 +142,9 @@ public: const std::string& visible_on() const noexcept { return visible_on_; } + const std::map& enum_labels() const noexcept { + return enum_labels_; + } bool is_editable() const noexcept { return editable_; } @@ -159,6 +176,7 @@ private: std::string description_; std::string widget_; std::string visible_on_; + std::map enum_labels_; bool editable_{}; bool creatable_{}; bool readable_{true}; @@ -186,18 +204,96 @@ struct No_Object_Validator { template void operator()(const T&) const noexcept {} }; +struct Object_List_Options { + std::string id_label{"ID"}; + std::string create_label{"Create"}; + std::string edit_label{"Edit"}; + std::string delete_label{"Delete"}; + std::string actions_label{"Actions"}; + std::string confirm_label{"Confirm Changes"}; + std::string delete_confirm{"Delete this record?"}; + std::string view_status_label{"View status ▾"}; + std::string management_label; + std::string default_order_by; + std::string default_order_dir{"asc"}; + bool user_reorderable_{}; + bool column_reorderable_{true}; +}; template class Object_Descriptor { public: using object_type = T; using validator_type = Validator; - Object_Descriptor(std::string name, std::string label, Validator validator, std::tuple fields) : name_(std::move(name)), label_(std::move(label)), validator_(std::move(validator)), fields_(std::move(fields)) {} + Object_Descriptor(std::string name, std::string label, Validator validator, std::tuple fields, Object_List_Options list_options = {}) : name_(std::move(name)), label_(std::move(label)), validator_(std::move(validator)), fields_(std::move(fields)), list_options_(std::move(list_options)) {} template auto validator(New_Validator value) const { - return Object_Descriptor(name_, label_, std::move(value), fields_); + return Object_Descriptor(name_, label_, std::move(value), fields_, list_options_); } - auto label(std::string value) const { - return Object_Descriptor(name_, std::move(value), validator_, fields_); + Object_Descriptor label(std::string value) const { + auto result = *this; + result.label_ = std::move(value); + return result; + } + Object_Descriptor id_label(std::string value) const { + auto result = *this; + result.list_options_.id_label = std::move(value); + return result; + } + Object_Descriptor create_label(std::string value) const { + auto result = *this; + result.list_options_.create_label = std::move(value); + return result; + } + Object_Descriptor edit_label(std::string value) const { + auto result = *this; + result.list_options_.edit_label = std::move(value); + return result; + } + Object_Descriptor delete_label(std::string value) const { + auto result = *this; + result.list_options_.delete_label = std::move(value); + return result; + } + Object_Descriptor actions_label(std::string value) const { + auto result = *this; + result.list_options_.actions_label = std::move(value); + return result; + } + Object_Descriptor confirm_label(std::string value) const { + auto result = *this; + result.list_options_.confirm_label = std::move(value); + return result; + } + Object_Descriptor delete_confirm(std::string value) const { + auto result = *this; + result.list_options_.delete_confirm = std::move(value); + return result; + } + Object_Descriptor view_status_label(std::string value) const { + auto result = *this; + result.list_options_.view_status_label = std::move(value); + return result; + } + Object_Descriptor management_label(std::string value) const { + auto result = *this; + result.list_options_.management_label = std::move(value); + return result; + } + Object_Descriptor default_sort(std::string field_name, bool descending = false) const { + auto result = *this; + result.list_options_.default_order_by = std::move(field_name); + result.list_options_.default_order_dir = descending ? "desc" : "asc"; + return result; + } + Object_Descriptor user_reorderable(bool value = true) const { + auto result = *this; + result.list_options_.user_reorderable_ = value; + return result; + } + Object_Descriptor column_reorderable(bool value = true) const { + auto result = *this; + result.list_options_.column_reorderable_ = value; + return result; } const std::string& name() const noexcept { return name_; @@ -211,11 +307,15 @@ public: const std::tuple& fields() const noexcept { return fields_; } + const Object_List_Options& list_options() const noexcept { + return list_options_; + } private: std::string name_; std::string label_; Validator validator_; std::tuple fields_; + Object_List_Options list_options_; }; template auto object(std::string name, Fields... fields) { diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index f37dd4e..8513360 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -24,17 +24,18 @@ template requires std::copy_constructible && std::assignable_from class Http_Resource { public: - Http_Resource(T& value, std::string path) : value_(value), path_(std::move(path)) {} + using Commit_Function = std::function; + Http_Resource(T& value, std::string path, Commit_Function commit = {}, std::mutex* shared_mutex = nullptr) : value_(value), path_(std::move(path)), commit_(std::move(commit)), shared_mutex_(shared_mutex) {} Json amis_schema() const { - std::scoped_lock lock(mutex_); - return to_amis_form_schema(value_, path_ + "/data", "Confirm Changes"); + std::scoped_lock lock(resource_mutex()); + return to_amis_form_schema(value_, path_ + "/data", describe().list_options().confirm_label); } void bind(httplib::Server& server) { server.Get(path_ + "/descriptor", [this](const httplib::Request&, httplib::Response& response) { write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_descriptor_json()}}); }); server.Get(path_ + "/data", [this](const httplib::Request&, httplib::Response& response) { - std::scoped_lock lock(mutex_); + std::scoped_lock lock(resource_mutex()); write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_frontend_json(value_)}}); }); server.Get(path_ + "/amis", [this](const httplib::Request&, httplib::Response& response) { @@ -43,12 +44,17 @@ public: server.Post(path_ + "/data", [this](const httplib::Request& request, httplib::Response& response) { try { const Json patch = Json::parse(request.body); - std::scoped_lock lock(mutex_); - const auto result = apply_frontend_patch(value_, patch); + std::scoped_lock lock(resource_mutex()); + T candidate = value_; + const auto result = apply_frontend_patch(candidate, patch); if(!result.success) { write_http_error(response, 422, result); return; } + if(commit_) { + commit_(candidate); + } + value_ = std::move(candidate); write_http_json(response, Json{{"status", 0}, {"msg", result.message}, {"data", to_frontend_json(value_)}}); } catch(const std::exception& error) { response.status = 400; @@ -57,8 +63,13 @@ public: }); } private: + std::mutex& resource_mutex() const noexcept { + return shared_mutex_ ? *shared_mutex_ : mutex_; + } T& value_; std::string path_; + Commit_Function commit_; + std::mutex* shared_mutex_{}; mutable std::mutex mutex_; }; template @@ -134,16 +145,7 @@ public: for(const auto& entry : entries_) { ordered_entries.push_back(&entry); } - if(request.has_param("orderBy")) { - const std::string order_by = request.get_param_value("orderBy"); - if(is_sortable_field(order_by)) { - const bool descending = request.has_param("orderDir") && request.get_param_value("orderDir") == "desc"; - std::stable_sort(ordered_entries.begin(), ordered_entries.end(), [&](const Entry* left, const Entry* right) { - const int comparison = compare_json_values(to_frontend_json(left->value).at(order_by), to_frontend_json(right->value).at(order_by)); - return descending ? comparison > 0 : comparison < 0; - }); - } - } + apply_request_sort(request, ordered_entries); const std::size_t offset = std::min((page - 1) * per_page, ordered_entries.size()); const std::size_t end = std::min(offset + per_page, ordered_entries.size()); Json items = Json::array(); @@ -180,6 +182,20 @@ public: write_http_json(response, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); } }); + if(describe().list_options().user_reorderable_) { + server.Post(path_ + "/order", [this](const httplib::Request& request, httplib::Response& response) { + try { + const Json input = Json::parse(request.body); + const auto ids = read_order_ids(input.at("ids")); + std::scoped_lock lock(mutex_); + reorder(ids); + write_http_json(response, Json{{"status", 0}, {"msg", "reordered"}, {"data", Json::object()}}); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + } + }); + } server.Put(item_pattern(), [this](const httplib::Request& request, httplib::Response& response) { update(request, response); }); @@ -237,6 +253,9 @@ private: return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; } static bool is_sortable_field(const std::string& name) { + if(name == "id") { + return true; + } bool sortable{}; std::apply([&](const auto&... field) { ((field.name() == name ? sortable = field.is_sortable() : false), ...); @@ -252,6 +271,48 @@ private: } return id; } + static std::vector read_order_ids(const Json& value) { + std::vector result; + if(value.is_array()) { + for(const auto& item : value) { + result.push_back(item.is_string() ? std::stoull(item.get()) : item.get()); + } + return result; + } + std::string text = value.get(); + std::size_t begin{}; + while(begin < text.size()) { + const auto end = text.find(',', begin); + result.push_back(std::stoull(text.substr(begin, end == std::string::npos ? text.size() - begin : end - begin))); + if(end == std::string::npos) { + break; + } + begin = end + 1; + } + return result; + } + void apply_request_sort(const httplib::Request& request, std::vector& entries) const { + const auto& options = describe().list_options(); + std::string order_by = options.default_order_by; + std::string order_dir = options.default_order_dir; + if(request.has_param("orderBy")) { + order_by = request.get_param_value("orderBy"); + order_dir = request.has_param("orderDir") ? request.get_param_value("orderDir") : "asc"; + } + if(order_by.empty() || !is_sortable_field(order_by)) { + return; + } + const bool descending = order_dir == "desc"; + std::stable_sort(entries.begin(), entries.end(), [&](const Entry* left, const Entry* right) { + int comparison{}; + if(order_by == "id") { + comparison = left->id < right->id ? -1 : left->id > right->id ? 1 : 0; + } else { + comparison = compare_json_values(to_frontend_json(left->value).at(order_by), to_frontend_json(right->value).at(order_by)); + } + return descending ? comparison > 0 : comparison < 0; + }); + } std::string item_pattern() const { return path_ + R"(/(\d+))"; } @@ -272,6 +333,24 @@ private: response.status = 404; write_http_json(response, Json{{"status", 404}, {"msg", "record not found"}, {"data", Json::object()}}); } + void reorder(const std::vector& ids) { + std::vector remaining = std::move(entries_); + std::vector reordered; + reordered.reserve(remaining.size()); + for(const auto id : ids) { + const auto iterator = std::find_if(remaining.begin(), remaining.end(), [id](const Entry& entry) { + return entry.id == id; + }); + if(iterator != remaining.end()) { + reordered.push_back(std::move(*iterator)); + remaining.erase(iterator); + } + } + for(auto& entry : remaining) { + reordered.push_back(std::move(entry)); + } + entries_ = std::move(reordered); + } void update(const httplib::Request& request, httplib::Response& response) { try { Json patch = Json::parse(request.body); diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index 899d855..fcc28ba 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -165,13 +165,14 @@ void append_constraints(Json& result) { } } template -Json enum_options() { +Json enum_options(const std::map& labels) { using Value = Unwrapped_Value_Type; Json result = Json::array(); - if constexpr (std::is_enum_v) { - for (const auto value : magic_enum::enum_values()) { + if constexpr(std::is_enum_v) { + for(const auto value : magic_enum::enum_values()) { const std::string name(magic_enum::enum_name(value)); - result.push_back(Json{{"label", make_label(name)}, {"value", name}}); + const auto iterator = labels.find(name); + result.push_back(Json{{"label", iterator == labels.end() ? make_label(name) : iterator->second}, {"value", name}}); } } return result; @@ -203,14 +204,16 @@ Json to_descriptor_json() { field_json["children"] = to_descriptor_json().at("fields"); } append_constraints(field_json); - const auto options = enum_options(); + const auto options = enum_options(item.enum_labels()); if (!options.empty()) { field_json["options"] = options; } fields.push_back(std::move(field_json)); }(), ...); }, descriptor.fields()); - return Json{{"protocol", "adminive.resource"}, {"protocol_version", 1}, {"name", descriptor.name()}, {"label", descriptor.label()}, {"value_type", "object"}, {"fields", std::move(fields)}}; + const auto& list_options = descriptor.list_options(); + Json list{{"id_label", list_options.id_label}, {"create_label", list_options.create_label}, {"edit_label", list_options.edit_label}, {"delete_label", list_options.delete_label}, {"actions_label", list_options.actions_label}, {"confirm_label", list_options.confirm_label}, {"delete_confirm", list_options.delete_confirm}, {"view_status_label", list_options.view_status_label}, {"management_label", list_options.management_label.empty() ? descriptor.label() + " Management" : list_options.management_label}, {"default_order_by", list_options.default_order_by}, {"default_order_dir", list_options.default_order_dir}, {"user_reorderable", list_options.user_reorderable_}, {"column_reorderable", list_options.column_reorderable_}}; + return Json{{"protocol", "adminive.resource"}, {"protocol_version", 1}, {"name", descriptor.name()}, {"label", descriptor.label()}, {"value_type", "object"}, {"list", std::move(list)}, {"fields", std::move(fields)}}; } template Json to_json(const T& value) { diff --git a/backend/src/config_store.cpp b/backend/src/config_store.cpp new file mode 100644 index 0000000..f566e82 --- /dev/null +++ b/backend/src/config_store.cpp @@ -0,0 +1,84 @@ +#include "config_store.hpp" +#include +#include +namespace adminive::example { +Config_Store::Config_Store(std::filesystem::path path) : path_(std::move(path)) { + load_or_create(); +} +Application_Config& Config_Store::data() noexcept { + return data_; +} +const Application_Config& Config_Store::data() const noexcept { + return data_; +} +std::mutex& Config_Store::mutex() noexcept { + return mutex_; +} +const std::filesystem::path& Config_Store::path() const noexcept { + return path_; +} +void Config_Store::persist_radio_service(const Radio_Service_Config& value) { + Application_Config candidate = data_; + candidate.radio_service = value; + persist_candidate(candidate); + data_ = std::move(candidate); +} +void Config_Store::persist_alerts(const Alert_Config& value) { + Application_Config candidate = data_; + candidate.alerts = value; + persist_candidate(candidate); + data_ = std::move(candidate); +} +void Config_Store::persist_serial_table(const Serial_Table_Config& value) { + Application_Config candidate = data_; + candidate.serial_table = value; + persist_candidate(candidate); + data_ = std::move(candidate); +} +void Config_Store::persist_network_table(const Network_Table_Config& value) { + Application_Config candidate = data_; + candidate.network_table = value; + persist_candidate(candidate); + data_ = std::move(candidate); +} +void Config_Store::persist_default_device_type(Device_Table_Type value) { + Application_Config candidate = data_; + candidate.default_device_type = value; + persist_candidate(candidate); + data_ = std::move(candidate); +} +void Config_Store::load_or_create() { + if(!std::filesystem::exists(path_)) { + persist_candidate(data_); + return; + } + std::ifstream input(path_); + if(!input) { + throw std::runtime_error("failed to open configuration file: " + path_.string()); + } + const auto source = Json::parse(input); + const auto result = assign_json(data_, source); + if(!result.success) { + throw std::runtime_error("invalid configuration file: " + result.message); + } +} +void Config_Store::persist_candidate(const Application_Config& value) { + std::filesystem::create_directories(path_.parent_path()); + auto temporary = path_; + temporary += ".tmp"; + { + std::ofstream output(temporary, std::ios::trunc); + if(!output) { + throw std::runtime_error("failed to write configuration file: " + temporary.string()); + } + output << to_json(value).dump(2) << '\n'; + } + std::error_code error; + std::filesystem::remove(path_, error); + error.clear(); + std::filesystem::rename(temporary, path_, error); + if(error) { + throw std::runtime_error("failed to replace configuration file: " + error.message()); + } +} +} diff --git a/backend/src/config_store.hpp b/backend/src/config_store.hpp new file mode 100644 index 0000000..1fc6c60 --- /dev/null +++ b/backend/src/config_store.hpp @@ -0,0 +1,25 @@ +#pragma once +#include "example_descriptors.hpp" +#include +#include +namespace adminive::example { +class Config_Store { +public: + explicit Config_Store(std::filesystem::path path); + Application_Config& data() noexcept; + const Application_Config& data() const noexcept; + std::mutex& mutex() noexcept; + const std::filesystem::path& path() const noexcept; + void persist_radio_service(const Radio_Service_Config& value); + void persist_alerts(const Alert_Config& value); + void persist_serial_table(const Serial_Table_Config& value); + void persist_network_table(const Network_Table_Config& value); + void persist_default_device_type(Device_Table_Type value); +private: + void load_or_create(); + void persist_candidate(const Application_Config& value); + std::filesystem::path path_; + Application_Config data_; + std::mutex mutex_; +}; +} diff --git a/backend/src/device_tables.cpp b/backend/src/device_tables.cpp new file mode 100644 index 0000000..3fcc428 --- /dev/null +++ b/backend/src/device_tables.cpp @@ -0,0 +1,139 @@ +#include "device_tables.hpp" +#include "magic_enum/magic_enum.hpp" +#include +namespace adminive::example { +namespace { +std::vector make_serial_rows() { + return { + Serial_Device_Row{"COM1", Range_Value{115200}, Serial_Parity::none, true}, + Serial_Device_Row{"COM3", Range_Value{57600}, Serial_Parity::even, true}, + Serial_Device_Row{"COM7", Range_Value{9600}, Serial_Parity::odd, false} + }; +} +std::vector make_network_rows() { + return { + Network_Device_Row{"192.168.1.20:7001", Network_Protocol::tcp, Range_Value{3000}, false}, + Network_Device_Row{"192.168.1.21:7002", Network_Protocol::udp, Range_Value{1500}, false}, + Network_Device_Row{"wss://gateway.example/device", Network_Protocol::websocket, Range_Value{5000}, true} + }; +} +Json make_table_page(std::string_view title, std::string_view color, Json config_form, Json table_page) { + Json heading{{"type", "tpl"}, {"tpl", "

" + std::string(title) + "

"}}; + return Json{{"type", "container"}, {"body", Json::array({std::move(heading), std::move(config_form), std::move(table_page.at("body"))})}}; +} +} +Serial_Device_Table::Serial_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().serial_table, "/admin/device_tables/serial/config", [&store](const Serial_Table_Config& value) { + store.persist_serial_table(value); +}, &store.mutex()), rows_("/admin/device_tables/serial/items", make_serial_rows()) {} +Device_Table_Type Serial_Device_Table::type() const noexcept { + return Device_Table_Type::serial; +} +std::string_view Serial_Device_Table::type_name() const noexcept { + return "serial"; +} +std::string_view Serial_Device_Table::type_label() const noexcept { + return "串口设备"; +} +Json Serial_Device_Table::amis_schema() const { + Serial_Table_Config config; + { + std::scoped_lock lock(store_.mutex()); + config = store_.data().serial_table; + } + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/serial/config/data", "确认修改"), rows_.amis_schema()); +} +void Serial_Device_Table::bind(httplib::Server& server) { + config_resource_.bind(server); + rows_.bind(server); +} +Network_Device_Table::Network_Device_Table(Config_Store& store) : store_(store), config_resource_(store.data().network_table, "/admin/device_tables/network/config", [&store](const Network_Table_Config& value) { + store.persist_network_table(value); +}, &store.mutex()), rows_("/admin/device_tables/network/items", make_network_rows()) {} +Device_Table_Type Network_Device_Table::type() const noexcept { + return Device_Table_Type::network; +} +std::string_view Network_Device_Table::type_name() const noexcept { + return "network"; +} +std::string_view Network_Device_Table::type_label() const noexcept { + return "网络设备"; +} +Json Network_Device_Table::amis_schema() const { + Network_Table_Config config; + { + std::scoped_lock lock(store_.mutex()); + config = store_.data().network_table; + } + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/network/config/data", "确认修改"), rows_.amis_schema()); +} +void Network_Device_Table::bind(httplib::Server& server) { + config_resource_.bind(server); + rows_.bind(server); +} +Device_Table_Manager::Device_Table_Manager(Config_Store& store) : store_(store) { + tables_.push_back(std::make_unique(store)); + tables_.push_back(std::make_unique(store)); +} +Json Device_Table_Manager::amis_schema() const { + Json options = Json::array(); + for(const auto& table : tables_) { + options.push_back(Json{{"label", table->type_label()}, {"value", table->type_name()}}); + } + std::string default_type; + { + std::scoped_lock lock(store_.mutex()); + default_type = std::string(magic_enum::enum_name(store_.data().default_device_type)); + } + const Json selector{{"type", "select"}, {"name", "device_type"}, {"label", "设备类型"}, {"options", std::move(options)}, {"clearable", false}}; + const Json save_default{{"type", "button"}, {"label", "保存为默认类型"}, {"level", "primary"}, {"actionType", "ajax"}, {"api", Json{{"method", "post"}, {"url", "/admin/device_tables/default"}, {"data", Json{{"device_type", "${device_type}"}}}}}}; + const Json dynamic_table{{"type", "service"}, {"id", "device_table_dynamic"}, {"name", "device_table_dynamic"}, {"schemaApi", Json{{"method", "get"}, {"url", "/admin/device_tables/schema?type=${device_type}"}}}, {"initFetchSchema", true}}; + return Json{{"type", "page"}, {"title", "多类型设备配置"}, {"data", Json{{"device_type", std::move(default_type)}}}, {"body", Json{{"type", "form"}, {"wrapWithPanel", false}, {"actions", Json::array()}, {"body", Json::array({selector, save_default, dynamic_table})}}}}; +} +void Device_Table_Manager::bind(httplib::Server& server) { + for(auto& table : tables_) { + table->bind(server); + } + server.Get("/admin/device_tables/schema", [this](const httplib::Request& request, httplib::Response& response) { + try { + write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", find(read_type(request)).amis_schema()}}); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + } + }); + server.Post("/admin/device_tables/default", [this](const httplib::Request& request, httplib::Response& response) { + try { + const auto input = Json::parse(request.body); + const auto parsed = magic_enum::enum_cast(input.at("device_type").get()); + if(!parsed) { + throw std::invalid_argument("unknown device table type"); + } + std::scoped_lock lock(store_.mutex()); + store_.persist_default_device_type(*parsed); + write_http_json(response, Json{{"status", 0}, {"msg", "default type saved"}, {"data", Json{{"device_type", input.at("device_type")}}}}); + } catch(const std::exception& error) { + response.status = 400; + write_http_json(response, Json{{"status", 400}, {"msg", error.what()}, {"data", Json::object()}}); + } + }); +} +Device_Table_Base& Device_Table_Manager::find(Device_Table_Type type) const { + for(const auto& table : tables_) { + if(table->type() == type) { + return *table; + } + } + throw std::invalid_argument("device table type is not registered"); +} +Device_Table_Type Device_Table_Manager::read_type(const httplib::Request& request) const { + if(request.has_param("type")) { + const auto parsed = magic_enum::enum_cast(request.get_param_value("type")); + if(parsed) { + return *parsed; + } + throw std::invalid_argument("unknown device table type"); + } + std::scoped_lock lock(store_.mutex()); + return store_.data().default_device_type; +} +} diff --git a/backend/src/device_tables.hpp b/backend/src/device_tables.hpp new file mode 100644 index 0000000..85712a5 --- /dev/null +++ b/backend/src/device_tables.hpp @@ -0,0 +1,53 @@ +#pragma once +#include "config_store.hpp" +#include +#include +#include +namespace adminive::example { +class Device_Table_Base { +public: + virtual ~Device_Table_Base() = default; + virtual Device_Table_Type type() const noexcept = 0; + virtual std::string_view type_name() const noexcept = 0; + virtual std::string_view type_label() const noexcept = 0; + virtual Json amis_schema() const = 0; + virtual void bind(httplib::Server& server) = 0; +}; +class Serial_Device_Table final : public Device_Table_Base { +public: + explicit Serial_Device_Table(Config_Store& store); + Device_Table_Type type() const noexcept override; + std::string_view type_name() const noexcept override; + std::string_view type_label() const noexcept override; + Json amis_schema() const override; + void bind(httplib::Server& server) override; +private: + Config_Store& store_; + Http_Resource config_resource_; + Http_Collection_Resource rows_; +}; +class Network_Device_Table final : public Device_Table_Base { +public: + explicit Network_Device_Table(Config_Store& store); + Device_Table_Type type() const noexcept override; + std::string_view type_name() const noexcept override; + std::string_view type_label() const noexcept override; + Json amis_schema() const override; + void bind(httplib::Server& server) override; +private: + Config_Store& store_; + Http_Resource config_resource_; + Http_Collection_Resource rows_; +}; +class Device_Table_Manager { +public: + explicit Device_Table_Manager(Config_Store& store); + Json amis_schema() const; + void bind(httplib::Server& server); +private: + Device_Table_Base& find(Device_Table_Type type) const; + Device_Table_Type read_type(const httplib::Request& request) const; + Config_Store& store_; + std::vector> tables_; +}; +} diff --git a/backend/src/example.cpp b/backend/src/example.cpp new file mode 100644 index 0000000..b45fc1e --- /dev/null +++ b/backend/src/example.cpp @@ -0,0 +1,57 @@ +#include "example.hpp" +#include +#include +#include +#include +#include +namespace adminive::example { +std::string format_utc_time(std::chrono::system_clock::time_point value) { + const std::time_t raw_time = std::chrono::system_clock::to_time_t(value); + std::tm utc_time{}; +#ifdef _WIN32 + gmtime_s(&utc_time, &raw_time); +#else + gmtime_r(&raw_time, &utc_time); +#endif + std::ostringstream stream; + stream << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); + return stream.str(); +} +void Radio_State::Even_Validator::operator()(const int& value) const { + if(value % 2 != 0) { + throw std::invalid_argument("buffer_count must be even"); + } +} +void Radio_State::Watermark_Validator::operator()(const Radio_State& value) const { + if(value.low_watermark > value.high_watermark) { + throw std::invalid_argument("low_watermark must not exceed high_watermark"); + } +} +Radio_Item_Status Radio_State::status() { + const auto now = std::chrono::system_clock::now(); + const auto seconds = std::chrono::duration_cast(now.time_since_epoch()).count(); + const auto state_index = (static_cast(seconds / 4) + static_cast(port.value())) % 4; + Radio_Operating_State state{}; + std::string color; + switch(state_index) { + case 0: + state = Radio_Operating_State::starting; + color = "#2563eb"; + break; + case 1: + state = Radio_Operating_State::running; + color = "#16a34a"; + break; + case 2: + state = Radio_Operating_State::degraded; + color = "#d97706"; + break; + default: + state = Radio_Operating_State::stopped; + color = "#dc2626"; + break; + } + ++status_sequence_; + return Radio_Item_Status{status_value(state, std::move(color)), status_value(status_sequence_, "#475569"), status_value(format_utc_time(now), "#475569")}; +} +} diff --git a/backend/src/example.hpp b/backend/src/example.hpp index e5ec307..3ce9a26 100644 --- a/backend/src/example.hpp +++ b/backend/src/example.hpp @@ -2,25 +2,10 @@ #include "adminive/adminive.hpp" #include #include -#include -#include -#include -#include #include #include namespace adminive::example { -inline std::string format_utc_time(std::chrono::system_clock::time_point value) { - const std::time_t raw_time = std::chrono::system_clock::to_time_t(value); - std::tm utc_time{}; -#ifdef _WIN32 - gmtime_s(&utc_time, &raw_time); -#else - gmtime_r(&raw_time, &utc_time); -#endif - std::ostringstream stream; - stream << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); - return stream.str(); -} +std::string format_utc_time(std::chrono::system_clock::time_point value); enum class Radio_Mode { receive, transmit, @@ -45,6 +30,20 @@ enum class Alert_Channel { email, webhook }; +enum class Device_Table_Type { + serial, + network +}; +enum class Serial_Parity { + none, + odd, + even +}; +enum class Network_Protocol { + tcp, + udp, + websocket +}; struct Server_Status { std::string service_state{"running"}; std::string server_time; @@ -87,143 +86,54 @@ struct Alert_Config { std::string highlight_color{"#d97706"}; Endpoint_Config webhook_endpoint{true, "127.0.0.1", Range_Value{9100}}; }; +struct Serial_Table_Config { + std::string group_name{"本地串口设备"}; + Range_Value default_baud_rate{115200}; + Serial_Parity default_parity{Serial_Parity::none}; + std::string accent_color{"#2563eb"}; +}; +struct Network_Table_Config { + std::string group_name{"网络终端"}; + Network_Protocol default_protocol{Network_Protocol::tcp}; + Range_Value timeout_ms{3000}; + bool tls_enabled{}; + std::string accent_color{"#16a34a"}; +}; +struct Application_Config { + Device_Table_Type default_device_type{Device_Table_Type::serial}; + Radio_Service_Config radio_service{}; + Alert_Config alerts{}; + Serial_Table_Config serial_table{}; + Network_Table_Config network_table{}; +}; struct Radio_State { struct Even_Validator { static constexpr int multiple_of = 2; static constexpr std::string_view message = "buffer_count must be even"; - void operator()(const int& value) const { - if(value % 2 != 0) { - throw std::invalid_argument("buffer_count must be even"); - } - } + void operator()(const int& value) const; }; struct Watermark_Validator { - void operator()(const Radio_State& value) const { - if(value.low_watermark > value.high_watermark) { - throw std::invalid_argument("low_watermark must not exceed high_watermark"); - } - } + void operator()(const Radio_State& value) const; }; Radio_Mode mode{Radio_Mode::receive}; Range_Value port{9999}; Validated_Value buffer_count{2}; int low_watermark{}; int high_watermark{}; - Radio_Item_Status status() { - const auto now = std::chrono::system_clock::now(); - const auto seconds = std::chrono::duration_cast(now.time_since_epoch()).count(); - const auto state_index = (static_cast(seconds / 4) + static_cast(port.value())) % 4; - Radio_Operating_State state{}; - std::string color; - switch(state_index) { - case 0: - state = Radio_Operating_State::starting; - color = "#2563eb"; - break; - case 1: - state = Radio_Operating_State::running; - color = "#16a34a"; - break; - case 2: - state = Radio_Operating_State::degraded; - color = "#d97706"; - break; - default: - state = Radio_Operating_State::stopped; - color = "#dc2626"; - break; - } - ++status_sequence_; - return Radio_Item_Status{status_value(state, std::move(color)), status_value(status_sequence_, "#475569"), status_value(format_utc_time(now), "#475569")}; - } + Radio_Item_Status status(); private: std::uint64_t status_sequence_{}; }; -} -namespace adminive { -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Server_Status; - return object("server_status", ADMINIVE_FIELD(T, service_state), ADMINIVE_FIELD(T, server_time), ADMINIVE_FIELD(T, uptime_seconds), ADMINIVE_FIELD(T, poll_sequence), ADMINIVE_FIELD(T, radio_state_count), ADMINIVE_FIELD(T, listen_port)).label("Server Status"); - } +struct Serial_Device_Row { + std::string port_name{"COM1"}; + Range_Value baud_rate{115200}; + Serial_Parity parity{Serial_Parity::none}; + bool enabled{true}; }; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_Item_Status; - return object("radio_item_status", ADMINIVE_FIELD_LABEL(T, operating_state, "State"), ADMINIVE_FIELD_LABEL(T, refresh_sequence, "Refresh Sequence"), ADMINIVE_FIELD_LABEL(T, refresh_time, "Refresh Time")).label("Runtime Status"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Endpoint_Config; - return object( - "endpoint_config", - ADMINIVE_FIELD_LABEL(T, enabled, "Enable Endpoint").editable().creatable().description("Controls whether this endpoint participates in the current configuration"), - ADMINIVE_FIELD_LABEL(T, host, "Host Address").editable().creatable().required().description("String input example"), - ADMINIVE_FIELD_LABEL(T, port, "Port").editable().creatable().required().description("Integer input example") - ).label("Endpoint"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Appearance_Config; - return object( - "appearance_config", - ADMINIVE_FIELD_LABEL(T, panel_title, "Panel Title").editable().creatable().required().description("String input example"), - ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().creatable().required().widget("input-date").description("Date selector example"), - ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color").editable().creatable().required().widget("input-color").description("Color selector example") - ).label("Appearance"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_Service_Config; - return object( - "radio_service_config", - ADMINIVE_FIELD_LABEL(T, profile_name, "Profile Name").editable().required().description("Editable string configuration"), - ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").editable().required().description("Enum options are generated by magic_enum"), - ADMINIVE_FIELD_LABEL(T, worker_count, "Worker Count").editable().required().description("Integer number input"), - ADMINIVE_FIELD_LABEL(T, receive_gain, "Receive Gain").editable().required().description("Floating-point number input"), - ADMINIVE_FIELD_LABEL(T, primary_endpoint, "Primary Endpoint").editable().description("First child configuration group"), - ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint").editable().visible_on("${mode == 'duplex'}").description("Displayed only when Operating Mode is duplex"), - ADMINIVE_FIELD_LABEL(T, appearance, "Appearance").editable().description("Nested date and color controls") - ).label("Radio Service Configuration"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Alert_Config; - return object( - "alert_config", - ADMINIVE_FIELD_LABEL(T, enabled, "Enable Alerts").editable(), - ADMINIVE_FIELD_LABEL(T, rule_name, "Rule Name").editable().required(), - ADMINIVE_FIELD_LABEL(T, channel, "Delivery Channel").editable().required().description("Enum options are generated by magic_enum"), - ADMINIVE_FIELD_LABEL(T, minimum_level, "Minimum Log Level").editable().required().description("Second enum example"), - ADMINIVE_FIELD_LABEL(T, repeat_minutes, "Repeat Interval Minutes").editable().required(), - ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date").editable().required().widget("input-date"), - ADMINIVE_FIELD_LABEL(T, highlight_color, "Highlight Color").editable().required().widget("input-color"), - ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint").editable().visible_on("${enabled && channel == 'webhook'}").description("Displayed only when alerts are enabled and channel is webhook") - ).label("Alert Configuration"); - } -}; -template <> -struct Type_Descriptor { - static auto get() { - using T = example::Radio_State; - return object( - "radio_state", - ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode").creatable().editable().required().list_label("Mode").order(5).sortable().description("Enum list column generated through magic_enum"), - ADMINIVE_FIELD(T, port).creatable().editable().required().description("Listening port").list_label("Listen Port").order(20).sortable(), - ADMINIVE_FIELD(T, buffer_count).creatable().editable().required().list_label("Buffers").order(10).sortable(), - ADMINIVE_FIELD(T, low_watermark).creatable().editable().order(30), - ADMINIVE_FIELD(T, high_watermark).creatable().editable().order(40) - ).label("Radio State").validator(T::Watermark_Validator{}); - } +struct Network_Device_Row { + std::string endpoint{"127.0.0.1:7001"}; + Network_Protocol protocol{Network_Protocol::tcp}; + Range_Value timeout_ms{3000}; + bool tls_enabled{}; }; } diff --git a/backend/src/example_descriptors.hpp b/backend/src/example_descriptors.hpp new file mode 100644 index 0000000..73117d1 --- /dev/null +++ b/backend/src/example_descriptors.hpp @@ -0,0 +1,156 @@ +#pragma once +#include "example.hpp" +namespace adminive { +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Server_Status; + return object("server_status", ADMINIVE_FIELD_LABEL(T, service_state, "服务状态"), ADMINIVE_FIELD_LABEL(T, server_time, "服务器时间"), ADMINIVE_FIELD_LABEL(T, uptime_seconds, "运行秒数"), ADMINIVE_FIELD_LABEL(T, poll_sequence, "轮询序号"), ADMINIVE_FIELD_LABEL(T, radio_state_count, "无线电配置数量"), ADMINIVE_FIELD_LABEL(T, listen_port, "监听端口")).label("后端状态"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_Item_Status; + return object("radio_item_status", ADMINIVE_FIELD_LABEL(T, operating_state, "运行状态"), ADMINIVE_FIELD_LABEL(T, refresh_sequence, "刷新序号"), ADMINIVE_FIELD_LABEL(T, refresh_time, "刷新时间")).label("实时状态"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Endpoint_Config; + return object( + "endpoint_config", + ADMINIVE_FIELD_LABEL(T, enabled, "启用端点").editable().creatable().description("控制当前端点是否参与运行"), + ADMINIVE_FIELD_LABEL(T, host, "主机地址").editable().creatable().required().description("字符串输入示例"), + ADMINIVE_FIELD_LABEL(T, port, "端口").editable().creatable().required().description("整数输入示例") + ).label("端点配置"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Appearance_Config; + return object( + "appearance_config", + ADMINIVE_FIELD_LABEL(T, panel_title, "面板标题").editable().creatable().required(), + ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().creatable().required().widget("input-date"), + ADMINIVE_FIELD_LABEL(T, accent_color, "主题颜色").editable().creatable().required().widget("input-color") + ).label("界面配置"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_Service_Config; + return object( + "radio_service_config", + ADMINIVE_FIELD_LABEL(T, profile_name, "配置名称").editable().required(), + ADMINIVE_FIELD_LABEL(T, mode, "运行模式").editable().required().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), + ADMINIVE_FIELD_LABEL(T, worker_count, "工作线程数").editable().required(), + ADMINIVE_FIELD_LABEL(T, receive_gain, "接收增益").editable().required(), + ADMINIVE_FIELD_LABEL(T, primary_endpoint, "主端点").editable(), + ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点").editable().visible_on("${mode == 'duplex'}"), + ADMINIVE_FIELD_LABEL(T, appearance, "界面显示").editable() + ).label("无线电服务配置").confirm_label("确认修改"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Alert_Config; + return object( + "alert_config", + ADMINIVE_FIELD_LABEL(T, enabled, "启用告警").editable(), + ADMINIVE_FIELD_LABEL(T, rule_name, "规则名称").editable().required(), + ADMINIVE_FIELD_LABEL(T, channel, "发送通道").editable().required().enum_label("控制台").enum_label("邮件").enum_label("Webhook"), + ADMINIVE_FIELD_LABEL(T, minimum_level, "最低日志级别").editable().required().enum_label("跟踪").enum_label("调试").enum_label("信息").enum_label("警告").enum_label("错误"), + ADMINIVE_FIELD_LABEL(T, repeat_minutes, "重复间隔(分钟)").editable().required(), + ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().required().widget("input-date"), + ADMINIVE_FIELD_LABEL(T, highlight_color, "高亮颜色").editable().required().widget("input-color"), + ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook 端点").editable().visible_on("${enabled && channel == 'webhook'}") + ).label("告警配置").confirm_label("确认修改"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Serial_Table_Config; + return object( + "serial_table_config", + ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), + ADMINIVE_FIELD_LABEL(T, default_baud_rate, "默认波特率").editable().required(), + ADMINIVE_FIELD_LABEL(T, default_parity, "默认校验位").editable().required().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), + ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().widget("input-color") + ).label("串口表格配置").confirm_label("确认修改"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Network_Table_Config; + return object( + "network_table_config", + ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), + ADMINIVE_FIELD_LABEL(T, default_protocol, "默认协议").editable().required().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), + ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间(毫秒)").editable().required(), + ADMINIVE_FIELD_LABEL(T, tls_enabled, "默认启用 TLS").editable(), + ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().widget("input-color") + ).label("网络表格配置").confirm_label("确认修改"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Application_Config; + return object( + "application_config", + ADMINIVE_FIELD_LABEL(T, default_device_type, "默认设备类型"), + ADMINIVE_FIELD_LABEL(T, radio_service, "无线电服务"), + ADMINIVE_FIELD_LABEL(T, alerts, "告警"), + ADMINIVE_FIELD_LABEL(T, serial_table, "串口表格"), + ADMINIVE_FIELD_LABEL(T, network_table, "网络表格") + ).label("Adminive 配置"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Radio_State; + return object( + "radio_state", + ADMINIVE_FIELD_LABEL(T, mode, "运行模式").creatable().editable().required().list_label("模式").order(5).sortable().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), + ADMINIVE_FIELD_LABEL(T, port, "监听端口").creatable().editable().required().list_label("监听端口").order(20).sortable(), + ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量").creatable().editable().required().list_label("缓冲区").order(10).sortable(), + ADMINIVE_FIELD_LABEL(T, low_watermark, "低水位").creatable().editable().list_label("低水位").order(30).sortable(), + ADMINIVE_FIELD_LABEL(T, high_watermark, "高水位").creatable().editable().list_label("高水位").order(40).sortable() + ).label("无线电状态").validator(T::Watermark_Validator{}).id_label("编号").create_label("新增").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这条无线电状态吗?").view_status_label("查看状态 ▾").management_label("无线电状态列表").default_sort("buffer_count"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Serial_Device_Row; + return object( + "serial_device", + ADMINIVE_FIELD_LABEL(T, port_name, "串口名称").creatable().editable().required().list_label("串口").order(10).sortable(), + ADMINIVE_FIELD_LABEL(T, baud_rate, "波特率").creatable().editable().required().list_label("波特率").order(20).sortable(), + ADMINIVE_FIELD_LABEL(T, parity, "校验位").creatable().editable().required().list_label("校验位").order(30).sortable().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), + ADMINIVE_FIELD_LABEL(T, enabled, "启用").creatable().editable().list_label("启用").order(40).sortable() + ).label("串口设备").id_label("编号").create_label("新增串口").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这个串口吗?").management_label("串口设备列表").user_reorderable(); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Network_Device_Row; + return object( + "network_device", + ADMINIVE_FIELD_LABEL(T, endpoint, "网络地址").creatable().editable().required().list_label("地址").order(10).sortable(), + ADMINIVE_FIELD_LABEL(T, protocol, "协议").creatable().editable().required().list_label("协议").order(20).sortable().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), + ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间").creatable().editable().required().list_label("超时(毫秒)").order(30).sortable(), + ADMINIVE_FIELD_LABEL(T, tls_enabled, "启用 TLS").creatable().editable().list_label("TLS").order(40).sortable() + ).label("网络设备").id_label("编号").create_label("新增网络设备").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这个网络设备吗?").management_label("网络设备列表").user_reorderable(); + } +}; +} diff --git a/backend/src/main.cpp b/backend/src/main.cpp index f48a7b7..d0b05ad 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -1,104 +1,53 @@ -#include "example.hpp" -#include +#include "server_app.hpp" #include -#include -#include #include #include -#include #ifndef ADMINIVE_FRONTEND_DIST_DIR #define ADMINIVE_FRONTEND_DIST_DIR "frontend_dist" #endif +#ifndef ADMINIVE_CONFIG_FILE +#define ADMINIVE_CONFIG_FILE "config/adminive.json" +#endif namespace { -using Alert_Config = adminive::example::Alert_Config; -using Radio_Item_Status = adminive::example::Radio_Item_Status; -using Radio_Mode = adminive::example::Radio_Mode; -using Radio_Service_Config = adminive::example::Radio_Service_Config; -using Radio_State = adminive::example::Radio_State; -Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int low_watermark, int high_watermark) { - Radio_State result; - result.mode = mode; - result.port = port; - result.buffer_count = buffer_count; - result.low_watermark = low_watermark; - result.high_watermark = high_watermark; - return result; +int read_port(int argc, char** argv) { + if(argc == 1) { + return 9999; + } + const std::string_view text(argv[1]); + int port{}; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); + if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { + return 0; + } + return port; } -adminive::Json make_admin_schema(const adminive::Http_Collection_Resource& states, const adminive::Http_Resource& service_config, const adminive::Http_Resource& alert_config) { - const adminive::Json states_schema = states.amis_schema(); - adminive::Json tabs = adminive::Json::array(); - tabs.push_back(adminive::Json{{"title", "Radio State List"}, {"body", states_schema.at("body")}}); - tabs.push_back(adminive::Json{{"title", "Radio Service Configuration"}, {"body", service_config.amis_schema()}}); - tabs.push_back(adminive::Json{{"title", "Alert Configuration"}, {"body", alert_config.amis_schema()}}); - return adminive::Json{{"type", "page"}, {"title", "Adminive Complete Example"}, {"subTitle", "CRUD, enum columns, lazy status, hierarchical configuration, linkage, date and color controls are defined by the backend"}, {"body", adminive::Json{{"type", "tabs"}, {"tabs", std::move(tabs)}}}}; +void describe_example() { + using namespace adminive::example; + Radio_State state; + const auto status_descriptor = adminive::to_status_descriptor_json(); + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_json(state).dump(2) << '\n'; + std::cout << status_descriptor.dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; + std::cout << adminive::to_descriptor_json().dump(2) << '\n'; } } int main(int argc, char** argv) { if(argc == 2 && std::string_view(argv[1]) == "--describe") { - Radio_State state; - Radio_Service_Config service_config; - Alert_Config alert_config; - const auto status_descriptor = adminive::to_status_descriptor_json(); - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_json(state).dump(2) << '\n'; - std::cout << status_descriptor.dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes").dump(2) << '\n'; - std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes").dump(2) << '\n'; + describe_example(); return 0; } - int port = 9999; - if(argc == 2) { - const std::string_view text(argv[1]); - const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), port); - if(error != std::errc{} || end != text.data() + text.size() || port < 1 || port > 65535) { - std::cerr << "invalid port\n"; - return 2; - } + const int port = read_port(argc, argv); + if(port == 0) { + std::cerr << "invalid port\n"; + return 2; } - const auto started_at = std::chrono::steady_clock::now(); - std::vector initial; - initial.push_back(make_radio_state(Radio_Mode::receive, 10001, 2, 16, 64)); - initial.push_back(make_radio_state(Radio_Mode::transmit, 10002, 4, 32, 128)); - initial.push_back(make_radio_state(Radio_Mode::duplex, 10003, 8, 64, 256)); - Radio_Service_Config radio_service_config; - Alert_Config alert_config; - httplib::Server server; - adminive::Http_Collection_Resource state_resource("/admin/radio_states", std::move(initial), "/admin/status"); - adminive::Http_Resource service_config_resource(radio_service_config, "/admin/config/radio"); - adminive::Http_Resource alert_config_resource(alert_config, "/admin/config/alerts"); - state_resource.register_status<&Radio_State::status>(2000); - state_resource.bind(server); - service_config_resource.bind(server); - alert_config_resource.bind(server); - server.Get("/admin/amis", [&](const httplib::Request&, httplib::Response& response) { - adminive::write_http_json(response, adminive::Json{{"status", 0}, {"msg", ""}, {"data", make_admin_schema(state_resource, service_config_resource, alert_config_resource)}}); - }); - std::atomic poll_sequence{}; - server.Get("/admin/status", [&](const httplib::Request&, httplib::Response& response) { - const auto now = std::chrono::system_clock::now(); - const auto uptime = std::chrono::duration_cast(std::chrono::steady_clock::now() - started_at).count(); - adminive::example::Server_Status status; - status.server_time = adminive::example::format_utc_time(now); - status.uptime_seconds = static_cast(uptime); - status.poll_sequence = poll_sequence.fetch_add(1, std::memory_order_relaxed) + 1; - status.radio_state_count = state_resource.size(); - status.listen_port = port; - adminive::write_http_json(response, adminive::Json{{"status", 0}, {"msg", ""}, {"data", adminive::to_frontend_json(status)}}); - }); - const std::filesystem::path frontend_dist = std::filesystem::path(ADMINIVE_FRONTEND_DIST_DIR).lexically_normal(); - if(!server.set_mount_point("/", frontend_dist.string())) { - std::cerr << "frontend build directory does not exist: " << frontend_dist.string() << '\n'; - std::cerr << "run npm run build from the project root before starting the backend\n"; - return 3; + try { + adminive::example::Server_App app(port, ADMINIVE_FRONTEND_DIST_DIR, ADMINIVE_CONFIG_FILE); + return app.run(); + } catch(const std::exception& error) { + std::cerr << error.what() << '\n'; + return 4; } - std::cout << "Adminive server listening on http://127.0.0.1:" << port << '\n'; - std::cout << "Serving frontend from " << frontend_dist.string() << '\n'; - std::cout << std::flush; - if(!server.listen("0.0.0.0", port)) { - std::cerr << "failed to start server\n"; - return 1; - } - return 0; } diff --git a/backend/src/server_app.cpp b/backend/src/server_app.cpp new file mode 100644 index 0000000..1dc85fc --- /dev/null +++ b/backend/src/server_app.cpp @@ -0,0 +1,75 @@ +#include "server_app.hpp" +#include +namespace adminive::example { +namespace { +Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int low_watermark, int high_watermark) { + Radio_State result; + result.mode = mode; + result.port = port; + result.buffer_count = buffer_count; + result.low_watermark = low_watermark; + result.high_watermark = high_watermark; + return result; +} +} +Server_App::Server_App(int port, std::filesystem::path frontend_dist, std::filesystem::path config_file) : port_(port), frontend_dist_(std::move(frontend_dist)), config_store_(std::move(config_file)), state_resource_("/admin/radio_states", make_radio_states(), "/admin/status"), service_config_resource_(config_store_.data().radio_service, "/admin/config/radio", [this](const Radio_Service_Config& value) { + config_store_.persist_radio_service(value); +}, &config_store_.mutex()), alert_config_resource_(config_store_.data().alerts, "/admin/config/alerts", [this](const Alert_Config& value) { + config_store_.persist_alerts(value); +}, &config_store_.mutex()), device_tables_(config_store_) { + state_resource_.register_status<&Radio_State::status>(2000); + bind_routes(); +} +int Server_App::run() { + if(!server_.set_mount_point("/", frontend_dist_.string())) { + std::cerr << "frontend build directory does not exist: " << frontend_dist_.string() << '\n'; + std::cerr << "run npm run build from the project root before starting the backend\n"; + return 3; + } + std::cout << "Adminive server listening on http://127.0.0.1:" << port_ << '\n'; + std::cout << "Serving frontend from " << frontend_dist_.string() << '\n'; + std::cout << "Configuration file " << config_store_.path().string() << '\n'; + std::cout << std::flush; + if(!server_.listen("0.0.0.0", port_)) { + std::cerr << "failed to start server\n"; + return 1; + } + return 0; +} +Json Server_App::amis_schema() const { + Json tabs = Json::array(); + const Json states_schema = state_resource_.amis_schema(); + tabs.push_back(Json{{"title", "无线电状态"}, {"body", states_schema.at("body")}}); + tabs.push_back(Json{{"title", "无线电服务配置"}, {"body", service_config_resource_.amis_schema()}}); + tabs.push_back(Json{{"title", "告警配置"}, {"body", alert_config_resource_.amis_schema()}}); + tabs.push_back(Json{{"title", "多类型设备"}, {"body", device_tables_.amis_schema()}}); + return Json{{"type", "page"}, {"title", "Adminive 完整示例"}, {"subTitle", "列表中文映射、后端排序、拖拽顺序、多态类型表格和持久化配置均由后端定义"}, {"body", Json{{"type", "tabs"}, {"tabs", std::move(tabs)}}}}; +} +std::vector Server_App::make_radio_states() { + return { + make_radio_state(Radio_Mode::receive, 10001, 2, 16, 64), + make_radio_state(Radio_Mode::transmit, 10002, 4, 32, 128), + make_radio_state(Radio_Mode::duplex, 10003, 8, 64, 256) + }; +} +void Server_App::bind_routes() { + state_resource_.bind(server_); + service_config_resource_.bind(server_); + alert_config_resource_.bind(server_); + device_tables_.bind(server_); + server_.Get("/admin/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", amis_schema()}}); + }); + server_.Get("/admin/status", [this](const httplib::Request&, httplib::Response& response) { + const auto now = std::chrono::system_clock::now(); + const auto uptime = std::chrono::duration_cast(std::chrono::steady_clock::now() - started_at_).count(); + Server_Status status; + status.server_time = format_utc_time(now); + status.uptime_seconds = static_cast(uptime); + status.poll_sequence = poll_sequence_.fetch_add(1, std::memory_order_relaxed) + 1; + status.radio_state_count = state_resource_.size(); + status.listen_port = port_; + write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_frontend_json(status)}}); + }); +} +} diff --git a/backend/src/server_app.hpp b/backend/src/server_app.hpp new file mode 100644 index 0000000..8bccc46 --- /dev/null +++ b/backend/src/server_app.hpp @@ -0,0 +1,26 @@ +#pragma once +#include "device_tables.hpp" +#include +#include +#include +namespace adminive::example { +class Server_App { +public: + Server_App(int port, std::filesystem::path frontend_dist, std::filesystem::path config_file); + int run(); + Json amis_schema() const; +private: + static std::vector make_radio_states(); + void bind_routes(); + int port_; + std::filesystem::path frontend_dist_; + Config_Store config_store_; + httplib::Server server_; + Http_Collection_Resource state_resource_; + Http_Resource service_config_resource_; + Http_Resource alert_config_resource_; + Device_Table_Manager device_tables_; + std::chrono::steady_clock::time_point started_at_{std::chrono::steady_clock::now()}; + std::atomic poll_sequence_{}; +}; +} diff --git a/backend/tests/test.cpp b/backend/tests/test.cpp index 6c7c967..8293fe0 100644 --- a/backend/tests/test.cpp +++ b/backend/tests/test.cpp @@ -1,160 +1,70 @@ -#include "example.hpp" +#include "config_store.hpp" +#include "device_tables.hpp" +#include "example_descriptors.hpp" #include -#include -#include +#include int main() { - using adminive::example::Alert_Config; - using adminive::example::Radio_Item_Status; - using adminive::example::Radio_Mode; - using adminive::example::Radio_Service_Config; - using adminive::example::Radio_State; + using namespace adminive::example; + const auto radio_descriptor = adminive::to_descriptor_json(); + assert(radio_descriptor.at("fields").at(0).at("name") == "mode"); + assert(radio_descriptor.at("fields").at(0).at("list_label") == "模式"); + assert(radio_descriptor.at("fields").at(0).at("options").at(0).at("label") == "接收"); + assert(radio_descriptor.at("fields").at(0).at("options").at(2).at("label") == "双工"); + assert(radio_descriptor.at("list").at("id_label") == "编号"); + assert(radio_descriptor.at("list").at("default_order_by") == "buffer_count"); + assert(radio_descriptor.at("list").at("default_order_dir") == "asc"); + const auto radio_crud = adminive::to_amis_crud_schema("/admin/radio_states"); + const auto& radio_table = radio_crud.at("body"); + assert(radio_table.at("columns").at(0).at("label") == "编号"); + assert(radio_table.at("columns").at(1).at("map").at("receive") == "接收"); + assert(radio_table.at("columns").at(2).at("name") == "buffer_count"); + assert(radio_table.at("headerToolbar").at(0).at("type") == "columns-toggler"); + assert(radio_table.at("headerToolbar").at(0).at("draggable") == true); + assert(radio_table.at("defaultParams").at("orderBy") == "buffer_count"); + assert(!radio_table.contains("draggable")); + const auto serial_descriptor = adminive::to_descriptor_json(); + assert(serial_descriptor.at("list").at("user_reorderable") == true); + assert(serial_descriptor.at("fields").at(2).at("options").at(0).at("label") == "无校验"); + const auto serial_crud = adminive::to_amis_crud_schema("/admin/device_tables/serial/items"); + assert(serial_crud.at("body").at("draggable") == true); + assert(serial_crud.at("body").at("saveOrderApi").at("url") == "/admin/device_tables/serial/items/order"); + const auto network_crud = adminive::to_amis_crud_schema("/admin/device_tables/network/items"); + assert(network_crud.at("body").at("columns").at(1).at("label") == "地址"); + assert(network_crud.at("body").at("columns").at(2).at("map").at("websocket") == "WebSocket"); + Radio_Service_Config radio_config; + const auto radio_form = adminive::to_amis_form_schema(radio_config, "/admin/config/radio/data", "确认修改"); + assert(radio_form.at("body").at(0).at("type") == "input-text"); + assert(radio_form.at("body").at(1).at("type") == "select"); + assert(radio_form.at("body").at(1).at("options").at(2).at("label") == "双工"); + assert(radio_form.at("body").at(5).at("visibleOn") == "${mode == 'duplex'}"); + assert(radio_form.at("body").at(6).at("body").at(1).at("type") == "input-date"); + assert(radio_form.at("body").at(6).at("body").at(2).at("type") == "input-color"); Radio_State state; - const auto descriptor = adminive::to_descriptor_json(); - assert(descriptor.at("name") == "radio_state"); - assert(descriptor.at("fields").size() == 5); - assert(descriptor.at("fields").at(0).at("name") == "mode"); - assert(descriptor.at("fields").at(0).at("label") == "Operating Mode"); - assert(descriptor.at("fields").at(0).at("list_label") == "Mode"); - assert(descriptor.at("fields").at(0).at("value_type") == "enum"); - assert(descriptor.at("fields").at(0).at("options").size() == 4); - assert(descriptor.at("fields").at(0).at("options").at(0).at("value") == "receive"); - assert(descriptor.at("fields").at(0).at("options").at(2).at("value") == "duplex"); - assert(descriptor.at("fields").at(0).at("order") == 5); - assert(descriptor.at("fields").at(0).at("sortable") == true); - assert(descriptor.at("fields").at(1).at("name") == "port"); - assert(descriptor.at("fields").at(1).at("list_label") == "Listen Port"); - assert(descriptor.at("fields").at(1).at("minimum") == 1); - assert(descriptor.at("fields").at(1).at("maximum") == 65535); - assert(descriptor.at("fields").at(2).at("name") == "buffer_count"); - assert(descriptor.at("fields").at(2).at("multiple_of") == 2); - const auto initial = adminive::to_json(state); - assert(initial.at("mode") == "receive"); - assert(initial.at("port") == 9999); - Radio_State assigned; - auto result = adminive::assign_json(assigned, adminive::Json{{"mode", "transmit"}, {"port", 7000}, {"buffer_count", 6}, {"low_watermark", 10}, {"high_watermark", 20}}); - assert(result.success); - assert(assigned.mode == Radio_Mode::transmit); - assert(assigned.port.value() == 7000); - result = adminive::apply_json_patch(assigned, adminive::Json{{"mode", "duplex"}, {"port", 7200}}); - assert(result.success); - assert(assigned.mode == Radio_Mode::duplex); - assert(assigned.port.value() == 7200); - result = adminive::apply_patch(state, adminive::Json{{"buffer_count", 3}}); - assert(!result.success); - assert(state.buffer_count.value() == 2); - result = adminive::apply_patch(state, adminive::Json{{"low_watermark", 64}, {"high_watermark", 32}}); - assert(!result.success); - assert(state.low_watermark == 0); - result = adminive::apply_patch(state, adminive::Json{{"missing", 1}}); - assert(!result.success); - assert(result.field_errors.contains("missing")); - Radio_State created; - result = adminive::apply_frontend_create(created, adminive::Json{{"mode", "maintenance"}, {"port", 7300}, {"buffer_count", 10}, {"low_watermark", 12}, {"high_watermark", 24}}); - assert(result.success); - assert(created.mode == Radio_Mode::maintenance); - result = adminive::apply_frontend_create(created, adminive::Json{{"port", 7300}}); - assert(!result.success); - assert(result.field_errors.contains("mode")); - assert(result.field_errors.contains("buffer_count")); - const auto form = adminive::to_amis_form_schema(state, "/admin/radio_state", "Confirm Changes"); - assert(form.at("type") == "form"); - assert(form.at("body").size() == 5); - assert(form.at("body").at(0).at("type") == "select"); - assert(form.at("body").at(0).at("options").size() == 4); - assert(form.at("body").at(1).at("type") == "input-number"); - assert(form.at("body").at(2).at("step") == 2); - assert(form.at("actions").size() == 2); - assert(form.at("actions").at(0).at("type") == "reset"); - assert(form.at("actions").at(1).at("type") == "submit"); - assert(form.at("actions").at(1).at("label") == "Confirm Changes"); - const auto crud = adminive::to_amis_crud_schema("/admin/radio_states"); - assert(crud.at("type") == "page"); - assert(crud.at("body").at("type") == "crud"); - assert(crud.at("body").at("columns").size() == 7); - assert(crud.at("body").at("columns").at(1).at("name") == "mode"); - assert(crud.at("body").at("columns").at(1).at("type") == "mapping"); - assert(crud.at("body").at("columns").at(1).at("map").at("duplex") == "Duplex"); - assert(crud.at("body").at("columns").at(2).at("name") == "buffer_count"); - assert(crud.at("body").at("columns").at(3).at("name") == "port"); - assert(!crud.at("body").contains("interval")); - const auto& create_form = crud.at("body").at("headerToolbar").at(1).at("dialog").at("body"); - assert(create_form.at("actions").at(1).at("type") == "submit"); - assert(create_form.at("actions").at(1).at("label") == "Create"); - const auto status_descriptor = adminive::to_status_descriptor_json(); - assert(status_descriptor.at("protocol") == "adminive.status"); - assert(status_descriptor.at("fields").size() == 3); - assert(status_descriptor.at("fields").at(0).at("name") == "operating_state"); - assert(status_descriptor.at("fields").at(0).at("value_type") == "enum"); - assert(status_descriptor.at("fields").at(0).at("color") == true); const auto first_status = adminive::to_status_json(state.status()); const auto second_status = adminive::to_status_json(state.status()); - assert(first_status.at("operating_state").contains("color")); - assert(!first_status.at("operating_state").at("color").get().empty()); assert(first_status.at("refresh_sequence").at("value") == 1); assert(second_status.at("refresh_sequence").at("value") == 2); - const auto status_crud = adminive::to_amis_crud_status_schema("/admin/radio_states", "/admin/status", &status_descriptor); - assert(status_crud.at("body").is_array()); - assert(status_crud.at("body").at(0).at("type") == "service"); - assert(status_crud.at("body").at(1).at("columns").size() == 8); - const auto& status_column = status_crud.at("body").at(1).at("columns").at(6); - assert(status_column.at("label") == "Runtime Status"); - assert(status_column.at("popOver").at("trigger") == "click"); - assert(status_column.at("popOver").at("body").at("api").at("url") == "/admin/radio_states/${id}/status"); - adminive::Http_Collection_Resource collection_resource("/admin/radio_states", std::vector{state}, "/admin/status"); - collection_resource.register_status<&Radio_State::status>(3000); - const auto registered_schema = collection_resource.amis_schema(); - const auto& registered_status_column = registered_schema.at("body").at(1).at("columns").at(6); - assert(registered_status_column.at("popOver").at("body").at("interval") == 3000); - Radio_Service_Config service_config; - const auto service_descriptor = adminive::to_descriptor_json(); - assert(service_descriptor.at("fields").size() == 7); - assert(service_descriptor.at("fields").at(0).at("value_type") == "string"); - assert(service_descriptor.at("fields").at(1).at("value_type") == "enum"); - assert(service_descriptor.at("fields").at(2).at("value_type") == "integer"); - assert(service_descriptor.at("fields").at(3).at("value_type") == "number"); - assert(service_descriptor.at("fields").at(4).at("value_type") == "object"); - assert(service_descriptor.at("fields").at(4).at("children").size() == 3); - assert(service_descriptor.at("fields").at(5).at("visible_on") == "${mode == 'duplex'}"); - assert(service_descriptor.at("fields").at(6).at("children").at(1).at("widget") == "input-date"); - assert(service_descriptor.at("fields").at(6).at("children").at(2).at("widget") == "input-color"); - const auto service_form = adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes"); - assert(service_form.at("body").at(0).at("type") == "input-text"); - assert(service_form.at("body").at(1).at("type") == "select"); - assert(service_form.at("body").at(2).at("type") == "input-number"); - assert(service_form.at("body").at(3).at("type") == "input-number"); - assert(service_form.at("body").at(4).at("type") == "fieldset"); - assert(service_form.at("body").at(4).at("body").at(1).at("name") == "primary_endpoint.host"); - assert(service_form.at("body").at(5).at("visibleOn") == "${mode == 'duplex'}"); - assert(service_form.at("body").at(6).at("body").at(1).at("type") == "input-date"); - assert(service_form.at("body").at(6).at("body").at(1).at("valueFormat") == "YYYY-MM-DD"); - assert(service_form.at("body").at(6).at("body").at(2).at("type") == "input-color"); - assert(service_form.at("actions").at(1).at("type") == "submit"); - result = adminive::apply_frontend_patch(service_config, adminive::Json{{"profile_name", "Duplex Profile"}, {"mode", "duplex"}, {"worker_count", 8}, {"receive_gain", 2.5}, {"primary_endpoint", adminive::Json{{"enabled", true}, {"host", "10.0.0.1"}, {"port", 9200}}}, {"backup_endpoint", adminive::Json{{"enabled", true}, {"host", "10.0.0.2"}, {"port", 9201}}}, {"appearance", adminive::Json{{"panel_title", "Duplex Radio"}, {"effective_date", "2026-08-07"}, {"accent_color", "#16a34a"}}}}); - assert(result.success); - assert(service_config.mode == Radio_Mode::duplex); - assert(service_config.primary_endpoint.host == "10.0.0.1"); - assert(service_config.appearance.accent_color == "#16a34a"); - Alert_Config alert_config; - const auto alert_form = adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes"); - assert(alert_form.at("body").size() == 8); - assert(alert_form.at("body").at(1).at("type") == "input-text"); - assert(alert_form.at("body").at(2).at("type") == "select"); - assert(alert_form.at("body").at(3).at("type") == "select"); - assert(alert_form.at("body").at(4).at("type") == "input-number"); - assert(alert_form.at("body").at(5).at("type") == "input-date"); - assert(alert_form.at("body").at(6).at("type") == "input-color"); - assert(alert_form.at("body").at(7).at("type") == "fieldset"); - assert(alert_form.at("body").at(7).at("visibleOn") == "${enabled && channel == 'webhook'}"); - adminive::Http_Resource service_resource(service_config, "/admin/config/radio"); - const auto resource_form = service_resource.amis_schema(); - assert(resource_form.at("api").at("method") == "post"); - assert(resource_form.at("api").at("url") == "/admin/config/radio/data"); - assert(resource_form.at("actions").at(1).at("label") == "Confirm Changes"); - adminive::example::Server_Status server_status; - server_status.server_time = "2026-08-06T00:00:00Z"; - server_status.poll_sequence = 7; - const auto status_json = adminive::to_frontend_json(server_status); - assert(status_json.at("service_state") == "running"); - assert(status_json.at("poll_sequence") == 7); + const auto config_root = std::filesystem::temp_directory_path() / "adminive_config_store_test"; + const auto config_file = config_root / "adminive.json"; + std::filesystem::remove_all(config_root); + { + Config_Store store(config_file); + assert(std::filesystem::exists(config_file)); + Radio_Service_Config changed = store.data().radio_service; + changed.profile_name = "持久化配置"; + std::scoped_lock lock(store.mutex()); + store.persist_radio_service(changed); + } + { + Config_Store store(config_file); + assert(store.data().radio_service.profile_name == "持久化配置"); + Device_Table_Manager manager(store); + const auto schema = manager.amis_schema(); + assert(schema.at("data").at("device_type") == "serial"); + assert(schema.at("body").at("body").at(0).at("label") == "设备类型"); + assert(schema.at("body").at("body").at(0).at("options").size() == 2); + assert(schema.at("body").at("body").at(2).at("schemaApi").at("url") == "/admin/device_tables/schema?type=${device_type}"); + } + std::filesystem::remove_all(config_root); return 0; } diff --git a/cmake/PackageZip.cmake b/cmake/PackageZip.cmake index 8fcbef4..36fdc92 100644 --- a/cmake/PackageZip.cmake +++ b/cmake/PackageZip.cmake @@ -1,6 +1,22 @@ if(PACKAGE_ZIP_RUN) + file(STRINGS "${PACKAGE_INCLUDE_FILE}" package_includes) file(STRINGS "${PACKAGE_EXCLUDE_FILE}" package_excludes) - file(GLOB_RECURSE package_files LIST_DIRECTORIES false RELATIVE "${PACKAGE_ROOT}" "${PACKAGE_ROOT}/*") + set(package_files) + foreach(include_pattern IN LISTS package_includes) + string(REPLACE "\\" "/" include_pattern "${include_pattern}") + string(REGEX REPLACE "^/+" "" include_pattern "${include_pattern}") + if(include_pattern STREQUAL "") + continue() + endif() + if(include_pattern MATCHES "/$") + string(REGEX REPLACE "/+$" "" include_pattern "${include_pattern}") + file(GLOB_RECURSE matched_files LIST_DIRECTORIES false RELATIVE "${PACKAGE_ROOT}" "${PACKAGE_ROOT}/${include_pattern}/*") + else() + file(GLOB_RECURSE matched_files LIST_DIRECTORIES false RELATIVE "${PACKAGE_ROOT}" "${PACKAGE_ROOT}/${include_pattern}") + endif() + list(APPEND package_files ${matched_files}) + endforeach() + list(REMOVE_DUPLICATES package_files) set(excluded_files) foreach(exclude_pattern IN LISTS package_excludes) string(REPLACE "\\" "/" exclude_pattern "${exclude_pattern}") @@ -20,7 +36,7 @@ if(PACKAGE_ZIP_RUN) list(REMOVE_DUPLICATES excluded_files) list(REMOVE_ITEM package_files ${excluded_files}) endif() - foreach(internal_file IN ITEMS "${PACKAGE_OUTPUT_FILE}" "${PACKAGE_FILE_LIST}" "${PACKAGE_EXCLUDE_FILE}") + foreach(internal_file IN ITEMS "${PACKAGE_OUTPUT_FILE}" "${PACKAGE_FILE_LIST}" "${PACKAGE_INCLUDE_FILE}" "${PACKAGE_EXCLUDE_FILE}") file(RELATIVE_PATH internal_relative "${PACKAGE_ROOT}" "${internal_file}") string(REPLACE "\\" "/" internal_relative "${internal_relative}") if(NOT internal_relative MATCHES "^\\.\\./") @@ -39,19 +55,26 @@ if(PACKAGE_ZIP_RUN) endif() return() endif() -function(add_project_zip_target target_name output_file exclude_list_name) - get_filename_component(package_root "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/.." ABSOLUTE) +function(add_project_zip_target target_name output_file root_directory include_list_name exclude_list_name) + if(IS_ABSOLUTE "${root_directory}") + get_filename_component(package_root "${root_directory}" ABSOLUTE) + else() + get_filename_component(package_root "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/${root_directory}" ABSOLUTE) + endif() if(IS_ABSOLUTE "${output_file}") - set(package_output_file "${output_file}") + get_filename_component(package_output_file "${output_file}" ABSOLUTE) else() get_filename_component(package_output_file "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/${output_file}" ABSOLUTE) endif() set(package_file_list "${CMAKE_CURRENT_BINARY_DIR}/${target_name}.files") + set(package_include_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}.includes") set(package_exclude_file "${CMAKE_CURRENT_BINARY_DIR}/${target_name}.excludes") + string(REPLACE ";" "\n" package_include_content "${${include_list_name}}") string(REPLACE ";" "\n" package_exclude_content "${${exclude_list_name}}") + file(WRITE "${package_include_file}" "${package_include_content}\n") file(WRITE "${package_exclude_file}" "${package_exclude_content}\n") add_custom_target(${target_name} - COMMAND "${CMAKE_COMMAND}" -DPACKAGE_ZIP_RUN=ON "-DPACKAGE_ROOT=${package_root}" "-DPACKAGE_OUTPUT_FILE=${package_output_file}" "-DPACKAGE_FILE_LIST=${package_file_list}" "-DPACKAGE_EXCLUDE_FILE=${package_exclude_file}" -P "${CMAKE_CURRENT_FUNCTION_LIST_FILE}" + COMMAND "${CMAKE_COMMAND}" -DPACKAGE_ZIP_RUN=ON "-DPACKAGE_ROOT=${package_root}" "-DPACKAGE_OUTPUT_FILE=${package_output_file}" "-DPACKAGE_FILE_LIST=${package_file_list}" "-DPACKAGE_INCLUDE_FILE=${package_include_file}" "-DPACKAGE_EXCLUDE_FILE=${package_exclude_file}" -P "${CMAKE_CURRENT_FUNCTION_LIST_FILE}" BYPRODUCTS "${package_output_file}" COMMENT "Creating ${package_output_file}" VERBATIM