From 9a25d37a90984f2f06ea759ee8f77dc34317a454 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Thu, 6 Aug 2026 16:48:57 +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 | 20 +- README.md | 362 ++++++++++++++++++------ backend/include/adminive/amis.hpp | 64 ++++- backend/include/adminive/descriptor.hpp | 13 +- backend/include/adminive/http.hpp | 9 +- backend/include/adminive/json.hpp | 11 +- backend/src/example.hpp | 124 +++++++- backend/src/main.cpp | 49 +++- backend/tests/test.cpp | 164 ++++++----- frontend/src/App.tsx | 2 +- 10 files changed, 612 insertions(+), 206 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 02d96a1..05ee9c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,16 +3,16 @@ project(Adminive VERSION 1.0.0 LANGUAGES CXX) include(CTest) add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/backend") set(adminive_package_excludes - "/.git/" - "/.idea/" - "/build/" - "/cmake-build-*/" - "/doc/" - "/frontend/node_modules/" - "/node_modules/" - "/frontend/frontend.zip" - "/frontend_dist/" - "*.tsbuildinfo" + "/.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) diff --git a/README.md b/README.md index ee27bfc..91acc80 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,240 @@ -# Adminive Backend-Driven CRUD And Lazy Status Demo +# Adminive Backend-Driven Complete Example -Adminive 使用 C++20 模板、Concepts、成员指针和 `magic_enum` 描述业务类型。同一份类型描述驱动对象与 JSON 双向转换、事务式赋值、字段权限、后端校验和 amis CRUD Schema。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段、状态字段或刷新策略。 +Adminive 使用 C++20 模板、Concepts、成员指针和 `magic_enum` 描述业务类型。同一份后端描述驱动 JSON 双向转换、事务式赋值、字段权限、校验、amis CRUD、枚举列表列、树状配置、联动条件、日期控件、颜色控件和一次性提交。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段或业务判断。 -## 列字段描述 +## 目录结构 -字段的数据名、表单标签和列表表头分别描述: +```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(T, buffer_count) - .label("Buffer Count") - .list_label("Buffers") - .order(10) +ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") + .creatable() + .editable() + .required() + .list_label("Mode") + .order(5) .sortable(); ``` - `name` 来自成员名称,用于 JSON 字段和接口参数。 -- `label` 用于表单和字段说明。 -- `list_label` 只用于列表表头,未设置时回退到 `label`。 +- `label` 用于表单标签。 +- `list_label` 用于列表表头,未设置时回退到 `label`。 - `order` 控制列表列顺序,未设置时按字段声明顺序生成默认值。 -- `sortable` 默认关闭,开启后后端接受 `orderBy` 和 `orderDir` 并执行排序。 +- `sortable` 控制该列能否排序,后端只接受已声明为可排序的字段。 -## 对象状态模型 +## 枚举列表项 -对象状态使用独立返回类型描述,不再混入 CRUD 对象字段: +```cpp +enum class Radio_Mode { + receive, + transmit, + duplex, + maintenance +}; +``` + +业务结构直接使用枚举: + +```cpp +struct Radio_State { + Radio_Mode mode{Radio_Mode::receive}; +}; +``` + +描述中不需要手写选项: + +```cpp +ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") + .creatable() + .editable() + .required() + .list_label("Mode") + .order(5) + .sortable(); +``` + +后端通过 `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"} + ] +} +``` + +表格列使用 amis `mapping`,接口数据仍然返回稳定的枚举字符串,例如 `"duplex"`。 + +## 树状配置 + +树结构由嵌套的已描述对象表达,不额外维护一份前端树配置: + +```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 表达式: + +```cpp +ADMINIVE_FIELD_LABEL(T, backup_endpoint, "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 +``` + +日期和颜色通过后端字段描述指定: + +```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 +``` + +`Radio Service Configuration` 展示字符串、枚举、整数、浮点数、树状子配置、日期和颜色。 + +`Alert Configuration` 展示布尔开关、字符串、两个枚举、数字、日期、颜色和条件显示的 webhook 子配置。 + +## 对象状态 + +对象状态使用独立返回类型描述,不混入 CRUD 对象字段: ```cpp struct Radio_Item_Status { @@ -32,7 +244,7 @@ struct Radio_Item_Status { }; ``` -`Status_Value` 同时携带值和颜色: +状态值同时携带颜色: ```json { @@ -43,76 +255,13 @@ struct Radio_Item_Status { } ``` -状态类型生成独立 JSON 模式: - -```text -GET /admin/radio_states/status/descriptor -``` - -协议名为 `adminive.status`,每个字段包含名称、标签、值类型以及是否支持颜色。 - -## 注册对象状态函数 - -业务对象提供无参数成员函数,返回已描述的状态类型: - -```cpp -Radio_Item_Status Radio_State::status(); -``` - 资源通过成员函数指针注册: ```cpp -adminive::Http_Collection_Resource resource( - "/admin/radio_states", - std::move(initial), - "/admin/status" -); resource.register_status<&Radio_State::status>(2000); -resource.bind(server); ``` -注册后自动生成: - -```text -GET /admin/radio_states/status/descriptor -GET /admin/radio_states/{id}/status -``` - -表格只显示 `View status ▾`。用户点击并打开状态 PopOver 后,其中的 amis `service` 才首次请求对象状态并按注册周期刷新。关闭 PopOver 后该状态视图被卸载,不再显示,也不通过 CRUD 列表轮询对象状态。普通 CRUD 列表本身不配置定时轮询。 - -## 前端 - -```powershell -cd frontend -npm install -npm run dev -``` - -开发地址为 `http://127.0.0.1:5173`,Vite 将 `/admin` 代理到 `http://127.0.0.1:9999`。 - -生产构建: - -```powershell -cd frontend -npm run build -``` - -输出目录: - -```text -frontend_dist -``` - -## 后端 - -```powershell -cmake -S . -B build -G Ninja -cmake --build build -ctest --test-dir build --output-on-failure -./build/backend/Adminive_Server.exe 9999 -``` - -访问 `http://127.0.0.1:9999`。 +只有用户打开某一行的 `View status` PopOver 后,该行状态才开始请求和刷新。 ## API @@ -129,6 +278,53 @@ 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 +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 +``` + +## 前端 + +创建外部 `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 +cmake --build build +ctest --test-dir build --output-on-failure +.\build\backend\Adminive_Server.exe 9999 +``` + +访问: + +```text +http://127.0.0.1:9999 ``` ## 源码压缩目标 @@ -137,14 +333,8 @@ DELETE /admin/radio_states/{id} cmake --build build --target package_zip ``` -输出文件位置由 `add_project_zip_target` 第二个参数明确指定: +输出文件位置由第二个参数明确指定,第三个参数传入排除列表变量名: ```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" adminive_package_excludes) ``` - -第三个参数是排除规则列表的变量名,函数内部解析列表内容。相对输出路径以 `cmake/PackageZip.cmake` 所在目录为基准。 diff --git a/backend/include/adminive/amis.hpp b/backend/include/adminive/amis.hpp index 3f39620..dba397f 100644 --- a/backend/include/adminive/amis.hpp +++ b/backend/include/adminive/amis.hpp @@ -7,21 +7,46 @@ #include #include namespace adminive { - -inline Json make_config_tree(std::string name, std::string label, Json children = Json::array()) { - return Json{{"name", std::move(name)}, {"label", std::move(label)}, {"children", std::move(children)}}; +inline std::string make_field_name(std::string_view prefix, std::string_view name) { + if(prefix.empty()) { + return std::string(name); + } + return std::string(prefix) + "." + std::string(name); } - -inline Json make_amis_control(const Json& field, std::string_view permission) { - Json control{{"name", field.at("name")}, {"label", field.at("label")}}; +inline Json make_amis_form_actions(std::string submit_label = "Apply") { + return Json::array({Json{{"type", "reset"}, {"label", "Reset"}}, Json{{"type", "submit"}, {"label", std::move(submit_label)}, {"level", "primary"}}}); +} +inline Json make_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { + const std::string name = field.at("name").get(); + const std::string full_name = make_field_name(prefix, name); + const std::string value_type = field.at("value_type").get(); + if(value_type == "object" && field.contains("children")) { + Json body = Json::array(); + for(const auto& child : field.at("children")) { + if(child.at("visible").get()) { + body.push_back(make_amis_control(child, permission, full_name)); + } + } + Json control{{"type", "fieldset"}, {"title", field.at("label")}, {"body", std::move(body)}, {"collapsable", true}, {"collapsed", false}}; + if(field.contains("description")) { + control["description"] = field.at("description"); + } + if(field.contains("visible_on")) { + control["visibleOn"] = field.at("visible_on"); + } + return control; + } + Json control{{"name", full_name}, {"label", field.at("label")}}; if(field.contains("description")) { control["description"] = field.at("description"); } + if(field.contains("visible_on")) { + control["visibleOn"] = field.at("visible_on"); + } if(!field.at(permission).get()) { control["type"] = "static"; return control; } - const std::string value_type = field.at("value_type"); if(field.contains("widget")) { control["type"] = field.at("widget"); } else if(value_type == "boolean") { @@ -33,6 +58,13 @@ inline Json make_amis_control(const Json& field, std::string_view permission) { } else { control["type"] = "input-text"; } + if(control.at("type") == "input-date") { + control["valueFormat"] = "YYYY-MM-DD"; + control["displayFormat"] = "YYYY-MM-DD"; + control["clearable"] = true; + } else if(control.at("type") == "input-color") { + control["clearable"] = true; + } if(field.at("required").get()) { control["required"] = true; } @@ -64,8 +96,12 @@ inline Json make_amis_form_body(const Json& descriptor, std::string_view permiss } inline Json make_amis_column(const Json& field) { Json column{{"name", field.at("name")}, {"label", field.at("list_label")}}; - if(field.contains("order")) { column["order"] = field.at("order"); } - if(field.contains("sortable")) { column["sortable"] = field.at("sortable"); } + if(field.contains("order")) { + column["order"] = field.at("order"); + } + if(field.contains("sortable")) { + column["sortable"] = field.at("sortable"); + } const std::string value_type = field.at("value_type"); if(value_type == "boolean") { column["type"] = "status"; @@ -107,9 +143,9 @@ inline Json make_amis_object_status_column(const Json& descriptor, std::string s return Json{{"type", "tpl"}, {"label", descriptor.at("label")}, {"tpl", R"(View status ▾)"}, {"popOver", pop_over}}; } template -Json to_amis_form_schema(const T& value, std::string submit_api = {}) { +Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { const Json descriptor = to_descriptor_json(); - Json result{{"type", "form"}, {"title", descriptor.at("label")}, {"data", to_frontend_json(value)}, {"body", make_amis_form_body(descriptor, "editable")}}; + Json result{{"type", "form"}, {"title", descriptor.at("label")}, {"data", to_frontend_json(value)}, {"body", make_amis_form_body(descriptor, "editable")}, {"actions", make_amis_form_actions(std::move(submit_label))}, {"affixFooter", true}}; if(!submit_api.empty()) { result["api"] = Json{{"method", "post"}, {"url", std::move(submit_api)}}; } @@ -122,7 +158,7 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n Json columns = Json::array({Json{{"name", "id"}, {"label", "Id"}}}); std::vector list_fields; for(const auto& field : descriptor.at("fields")) { - if(field.at("readable").get() && field.at("list_visible").get()) { + if(field.at("readable").get() && field.at("list_visible").get() && field.at("value_type") != "object") { list_fields.push_back(field); } } @@ -135,8 +171,8 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n if(status_descriptor) { columns.push_back(make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval)); } - const Json create_form{{"type", "form"}, {"api", Json{{"method", "post"}, {"url", base_api}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "creatable")}}; - const Json edit_form{{"type", "form"}, {"api", Json{{"method", "put"}, {"url", base_api + "/${id}"}}}, {"reload", crud_id}, {"body", make_amis_form_body(descriptor, "editable")}}; + 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}}}}; 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}}}}); diff --git a/backend/include/adminive/descriptor.hpp b/backend/include/adminive/descriptor.hpp index fad6b51..2127638 100644 --- a/backend/include/adminive/descriptor.hpp +++ b/backend/include/adminive/descriptor.hpp @@ -26,8 +26,8 @@ inline std::string make_label(std::string_view name) { std::string result; result.reserve(name.size()); bool uppercase = true; - for (const char value : name) { - if (value == '_') { + for(const char value : name) { + if(value == '_') { result.push_back(' '); uppercase = true; continue; @@ -90,6 +90,11 @@ public: result.widget_ = std::move(value); return result; } + Field_Descriptor visible_on(std::string value) const { + auto result = *this; + result.visible_on_ = std::move(value); + return result; + } Field_Descriptor list_label(std::string value) const { auto result = *this; result.list_label_ = std::move(value); @@ -120,6 +125,9 @@ public: const std::string& widget() const noexcept { return widget_; } + const std::string& visible_on() const noexcept { + return visible_on_; + } bool is_editable() const noexcept { return editable_; } @@ -150,6 +158,7 @@ private: std::string list_label_; std::string description_; std::string widget_; + std::string visible_on_; bool editable_{}; bool creatable_{}; bool readable_{true}; diff --git a/backend/include/adminive/http.hpp b/backend/include/adminive/http.hpp index d162fcf..f37dd4e 100644 --- a/backend/include/adminive/http.hpp +++ b/backend/include/adminive/http.hpp @@ -25,6 +25,10 @@ requires std::copy_constructible && std::assignable_from class Http_Resource { public: Http_Resource(T& value, std::string path) : value_(value), path_(std::move(path)) {} + Json amis_schema() const { + std::scoped_lock lock(mutex_); + return to_amis_form_schema(value_, path_ + "/data", "Confirm Changes"); + } 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()}}); @@ -34,8 +38,7 @@ public: write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_frontend_json(value_)}}); }); server.Get(path_ + "/amis", [this](const httplib::Request&, httplib::Response& response) { - std::scoped_lock lock(mutex_); - write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", to_amis_form_schema(value_, path_ + "/data")}}); + write_http_json(response, Json{{"status", 0}, {"msg", ""}, {"data", amis_schema()}}); }); server.Post(path_ + "/data", [this](const httplib::Request& request, httplib::Response& response) { try { @@ -56,7 +59,7 @@ public: private: T& value_; std::string path_; - std::mutex mutex_; + mutable std::mutex mutex_; }; template requires std::default_initializable && std::copy_constructible && std::assignable_from diff --git a/backend/include/adminive/json.hpp b/backend/include/adminive/json.hpp index c71f8c2..899d855 100644 --- a/backend/include/adminive/json.hpp +++ b/backend/include/adminive/json.hpp @@ -36,6 +36,9 @@ Json to_json(const T& value); template Json to_frontend_json(const T& value); template +requires std::default_initializable +Json to_descriptor_json(); +template requires std::default_initializable && std::copy_constructible && std::assignable_from Update_Result assign_json(T& target, const Json& value); template @@ -190,9 +193,15 @@ Json to_descriptor_json() { if (!item.description().empty()) { field_json["description"] = item.description(); } - if (!item.widget().empty()) { + if(!item.widget().empty()) { field_json["widget"] = item.widget(); } + if(!item.visible_on().empty()) { + field_json["visible_on"] = item.visible_on(); + } + if constexpr(Described_Type) { + field_json["children"] = to_descriptor_json().at("fields"); + } append_constraints(field_json); const auto options = enum_options(); if (!options.empty()) { diff --git a/backend/src/example.hpp b/backend/src/example.hpp index 666202e..e5ec307 100644 --- a/backend/src/example.hpp +++ b/backend/src/example.hpp @@ -21,12 +21,30 @@ inline std::string format_utc_time(std::chrono::system_clock::time_point value) stream << std::put_time(&utc_time, "%Y-%m-%dT%H:%M:%SZ"); return stream.str(); } +enum class Radio_Mode { + receive, + transmit, + duplex, + maintenance +}; enum class Radio_Operating_State { starting, running, degraded, stopped }; +enum class Log_Level { + trace, + debug, + info, + warning, + error +}; +enum class Alert_Channel { + dashboard, + email, + webhook +}; struct Server_Status { std::string service_state{"running"}; std::string server_time; @@ -40,23 +58,53 @@ struct Radio_Item_Status { Status_Value refresh_sequence; Status_Value refresh_time; }; +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{true, "127.0.0.2", Range_Value{9001}}; + Appearance_Config appearance{}; +}; +struct Alert_Config { + bool enabled{true}; + std::string rule_name{"Radio Health Alert"}; + Alert_Channel channel{Alert_Channel::dashboard}; + Log_Level minimum_level{Log_Level::warning}; + Range_Value repeat_minutes{10}; + std::string effective_date{"2026-08-06"}; + std::string highlight_color{"#d97706"}; + Endpoint_Config webhook_endpoint{true, "127.0.0.1", Range_Value{9100}}; +}; 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) { + if(value % 2 != 0) { throw std::invalid_argument("buffer_count must be even"); } } }; struct Watermark_Validator { void operator()(const Radio_State& value) const { - if (value.low_watermark > value.high_watermark) { + if(value.low_watermark > value.high_watermark) { throw std::invalid_argument("low_watermark must not exceed high_watermark"); } } }; + Radio_Mode mode{Radio_Mode::receive}; Range_Value port{9999}; Validated_Value buffer_count{2}; int low_watermark{}; @@ -67,7 +115,7 @@ struct Radio_State { const auto state_index = (static_cast(seconds / 4) + static_cast(port.value())) % 4; Radio_Operating_State state{}; std::string color; - switch (state_index) { + switch(state_index) { case 0: state = Radio_Operating_State::starting; color = "#2563eb"; @@ -108,18 +156,74 @@ struct Type_Descriptor { } }; 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(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{}); + 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{}); } }; } diff --git a/backend/src/main.cpp b/backend/src/main.cpp index c684ae7..f48a7b7 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -10,25 +10,42 @@ #define ADMINIVE_FRONTEND_DIST_DIR "frontend_dist" #endif namespace { -adminive::example::Radio_State make_radio_state(int port, int buffer_count, int low_watermark, int high_watermark) { - adminive::example::Radio_State result; +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; } +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)}}}}; +} } int main(int argc, char** argv) { - using Radio_Item_Status = adminive::example::Radio_Item_Status; - using Radio_State = adminive::example::Radio_State; 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_amis_crud_status_schema("/admin/radio_states", "/admin/status", &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'; return 0; } int port = 9999; @@ -42,15 +59,21 @@ int main(int argc, char** argv) { } const auto started_at = std::chrono::steady_clock::now(); std::vector initial; - initial.push_back(make_radio_state(10001, 2, 16, 64)); - initial.push_back(make_radio_state(10002, 4, 32, 128)); - initial.push_back(make_radio_state(10003, 8, 64, 256)); + 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 resource("/admin/radio_states", std::move(initial), "/admin/status"); - resource.register_status<&Radio_State::status>(2000); - resource.bind(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", resource.amis_schema()}}); + 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) { @@ -60,7 +83,7 @@ int main(int argc, char** argv) { 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 = resource.size(); + 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)}}); }); diff --git a/backend/tests/test.cpp b/backend/tests/test.cpp index a2e5059..6c7c967 100644 --- a/backend/tests/test.cpp +++ b/backend/tests/test.cpp @@ -2,86 +2,84 @@ #include #include #include -namespace test_types { -enum class Radio_Mode { - receive, - transmit -}; -struct Enum_State { - Radio_Mode mode{Radio_Mode::receive}; -}; -} -namespace adminive { -template <> -struct Type_Descriptor { - static auto get() { - using T = test_types::Enum_State; - return object("enum_state", ADMINIVE_FIELD(T, mode).creatable().editable()); - } -}; -} 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; Radio_State state; const auto descriptor = adminive::to_descriptor_json(); assert(descriptor.at("name") == "radio_state"); - assert(descriptor.at("fields").size() == 4); - assert(descriptor.at("fields").at(0).at("name") == "port"); - assert(descriptor.at("fields").at(0).at("label") == "Port"); - assert(descriptor.at("fields").at(0).at("list_label") == "Listen Port"); - assert(descriptor.at("fields").at(1).at("label") == "Buffer Count"); - assert(descriptor.at("fields").at(1).at("list_label") == "Buffers"); - assert(descriptor.at("fields").at(0).at("order") == 20); - assert(descriptor.at("fields").at(1).at("order") == 10); + 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(2).at("sortable") == false); - assert(descriptor.at("fields").at(0).at("minimum") == 1); - assert(descriptor.at("fields").at(0).at("maximum") == 65535); - assert(descriptor.at("fields").at(1).at("multiple_of") == 2); + 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{{"port", 7000}, {"buffer_count", 6}, {"low_watermark", 10}, {"high_watermark", 20}}); + 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); - const auto constructed = adminive::from_json(adminive::Json{{"port", 7100}, {"buffer_count", 8}, {"low_watermark", 11}, {"high_watermark", 22}}); - assert(constructed.port.value() == 7100); - result = adminive::apply_json_patch(assigned, adminive::Json{{"port", 7200}}); + 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{{"port", 9000}, {"buffer_count", 4}, {"low_watermark", 16}, {"high_watermark", 32}}); - assert(result.success); - assert(state.port.value() == 9000); result = adminive::apply_patch(state, adminive::Json{{"buffer_count", 3}}); assert(!result.success); - assert(state.buffer_count.value() == 4); + 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 == 16); + 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{{"port", 7300}, {"buffer_count", 10}, {"low_watermark", 12}, {"high_watermark", 24}}); + 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); + const auto form = adminive::to_amis_form_schema(state, "/admin/radio_state", "Confirm Changes"); assert(form.at("type") == "form"); - assert(form.at("body").size() == 4); - assert(form.at("body").at(0).at("type") == "input-number"); - assert(form.at("body").at(1).at("step") == 2); + 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() == 6); - assert(crud.at("body").at("columns").at(1).at("name") == "buffer_count"); - assert(crud.at("body").at("columns").at(1).at("label") == "Buffers"); - assert(crud.at("body").at("columns").at(2).at("name") == "port"); - assert(crud.at("body").at("columns").at(2).at("label") == "Listen Port"); + 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); @@ -97,32 +95,66 @@ int main() { 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(0).at("api").at("url") == "/admin/status"); - assert(status_crud.at("body").at(1).at("type") == "crud"); - assert(status_crud.at("body").at(1).at("columns").size() == 7); - const auto& status_column = status_crud.at("body").at(1).at("columns").at(5); + 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("type") == "service"); assert(status_column.at("popOver").at("body").at("api").at("url") == "/admin/radio_states/${id}/status"); - assert(status_column.at("popOver").at("body").at("interval") == 2000); - adminive::Http_Collection_Resource resource("/admin/radio_states", std::vector{state}, "/admin/status"); - resource.register_status<&Radio_State::status>(3000); - const auto registered_schema = resource.amis_schema(); - const auto& registered_status_column = registered_schema.at("body").at(1).at("columns").at(5); + 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); - test_types::Enum_State enum_state; - const auto enum_descriptor = adminive::to_descriptor_json(); - assert(enum_descriptor.at("fields").at(0).at("options").size() == 2); - result = adminive::apply_patch(enum_state, adminive::Json{{"mode", "transmit"}}); - assert(result.success); - assert(enum_state.mode == test_types::Radio_Mode::transmit); - assert(adminive::to_json(enum_state).at("mode") == "transmit"); return 0; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b0778b..34eb3f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,7 +33,7 @@ export default function App() {

Adminive

-

页面结构、对象字段、状态展示、刷新周期和颜色均由后端 JSON 描述驱动。

+

列表、枚举、树状配置、联动条件、状态刷新和输入控件均由后端 JSON 描述驱动。