分离 json magic_enum 依赖

This commit is contained in:
2026-08-06 20:33:28 +08:00
parent 21eacf2f26
commit f060662ffd
16 changed files with 2094 additions and 762 deletions
+344 -153
View File
@@ -1,10 +1,146 @@
# Adminive Backend-Driven Example
# Adminive Backend-Driven Complete Example
Adminive 使用 C++20 模板、成员指针和 `magic_enum` 生成后端描述。前端只负责渲染 `/admin/amis` 返回的 amis Schema,不包含业务字段、枚举映射、列顺序、排序规则或配置联动
Adminive 使用 C++20 模板、Concepts 和可替换适配器描述业务类型。同一份后端描述驱动 JSON 双向转换、事务式赋值、字段权限、校验、amis CRUD、枚举列表列、树状配置、联动条件、日期控件、颜色控件和一次性提交。核心层不依赖具体 JSON 库、枚举反射库或结构体反射库。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段或业务判断
## 数据名称与界面名称
## 适配层
字段成员名继续作为稳定的数据名称
核心目标只提供描述器、转换流程和 amis Schema 生成
```cmake
target_link_libraries(your_target PRIVATE Adminive::Core)
```
HTTP 资源层单独链接:
```cmake
target_link_libraries(your_target PRIVATE Adminive::Http)
```
项目自带的 `nlohmann::json``magic_enum` 桥接不会被核心头自动包含。需要默认实现时显式包含:
```cpp
#include "adminive/adapters/nlohmann_json.hpp"
#include "adminive/adapters/magic_enum.hpp"
using Json = nlohmann::json;
```
### JSON 适配
外部 JSON 类型通过 `Json_Adapter<Json>` 接入。适配器负责对象、数组、标量、字段访问、解析和输出。所有转换入口显式携带 JSON 类型:
```cpp
auto descriptor = adminive::to_descriptor_json<Json, Config>();
auto encoded = adminive::to_json<Json>(config);
auto result = adminive::apply_frontend_patch<Json>(config, input);
```
### 外部值类型适配
外部包装类型不需要继承 Adminive 类型。可以针对全部 JSON 实现提供通用适配,也可以只针对某个 JSON 类型特化:
```cpp
template <class Json>
struct adminive::Value_Adapter<Copyable_Atomic_Int, Json> {
using value_type = int;
static int read(const Copyable_Atomic_Int& value) noexcept {
return value.load();
}
static void write(Copyable_Atomic_Int& target, int value) noexcept {
target.store(value);
}
};
```
需要完全控制某种 JSON 的编码时,适配器可以提供 `encode()``decode()``type_name``append_schema()`
### 枚举适配
核心仅调用 `Enum_Adapter<Enum>``adminive/adapters/magic_enum.hpp` 是可选桥接,也可以为单个枚举自行实现:
```cpp
template <>
struct adminive::Enum_Adapter<My_Mode> {
static std::vector<My_Mode> values();
static std::string_view name(My_Mode value);
static std::optional<My_Mode> cast(std::string_view value);
};
```
### PFR 或其他结构体反射适配
核心通过 `Reflection_Adapter<T>` 接收字段数量、字段名称和按索引访问。外部可以在实现层用 Boost.PFR 完成桥接,Adminive 本身不包含 Boost.PFR。描述器使用 `reflected_object()``reflected_object_with()` 生成字段。
```cpp
template <>
struct adminive::Reflection_Adapter<Config> {
static constexpr std::size_t field_count = boost::pfr::tuple_size_v<Config>;
template <std::size_t Index>
static decltype(auto) get(Config& value) {
return boost::pfr::get<Index>(value);
}
template <std::size_t Index>
static decltype(auto) get(const Config& value) {
return boost::pfr::get<Index>(value);
}
template <std::size_t Index>
static constexpr std::string_view name() {
return boost::pfr::get_name<Index, Config>();
}
};
```
公开且无歧义的基类成员也可以直接注册到派生类型描述器,字段所有者不再要求与最终对象类型完全相同。
## 目录结构
```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 {
@@ -12,179 +148,260 @@ struct Radio_State {
};
```
界面标签、列表表头和枚举显示名称在描述端单独配置
描述中不需要手写选项
```cpp
ADMINIVE_FIELD_LABEL(T, mode, "运行模式")
.list_label("模式")
.sortable()
.enum_label<Radio_Mode::receive>("接收")
.enum_label<Radio_Mode::transmit>("发送")
.enum_label<Radio_Mode::duplex>("双工")
.enum_label<Radio_Mode::maintenance>("维护");
```
接口仍返回:
```json
{
"mode": "duplex"
}
```
表格显示为“模式:双工”。`magic_enum` 负责枚举值发现和稳定字符串生成,`.enum_label` 只覆盖界面文字。
## 列顺序与用户排序
字段的默认列顺序和是否允许按值排序由后端描述:
```cpp
ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量")
.list_label("缓冲区")
.order(10)
ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode")
.creatable()
.editable()
.required()
.list_label("Mode")
.order(5)
.sortable();
```
对象描述可以定义默认排序
示例通过可选的 `magic_enum` 桥接生成;核心只依赖 `Enum_Adapter`
```cpp
object<T>(/* fields */)
.default_sort("buffer_count");
```json
{
"name": "mode",
"value_type": "enum",
"options": [
{"label": "Receive", "value": "receive"},
{"label": "Transmit", "value": "transmit"},
{"label": "Duplex", "value": "duplex"},
{"label": "Maintenance", "value": "maintenance"}
]
}
```
用户点击可排序表头时,前端发送 `orderBy``orderDir`,后端只接受声明为 `.sortable()` 的字段
表格列使用 amis `mapping`,接口数据仍然返回稳定的枚举字符串,例如 `"duplex"`
用户也可以拖拽调整行顺序:
```cpp
object<T>(/* fields */)
.user_reorderable();
```
该配置会生成 `draggable``saveOrderApi`,后端提供 `POST <resource>/order` 保存当前运行期顺序。列选择菜单支持拖拽调整列先后,默认由 `.column_reorderable()` 控制。
## CRUD 中文文字
CRUD 固定文字也在对象描述中配置:
```cpp
object<T>(/* 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<int, 1, 65535> 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<int, 1, 64> worker_count{4};
double receive_gain{1.25};
Endpoint_Config primary_endpoint{};
Endpoint_Config backup_endpoint{};
Appearance_Config appearance{};
};
```
嵌套对象在界面中生成 `fieldset`。字符串、整数、浮点数、布尔、枚举、日期和颜色分别生成对应控件。联动条件仍由后端定义
界面结构为
```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, "备用端点")
ADMINIVE_FIELD_LABEL(T, backup_endpoint, "Backup Endpoint")
.editable()
.visible_on("${mode == 'duplex'}");
```
配置文件位于:
`mode` 不是 `duplex` 时,整个 `Backup Endpoint` 子树隐藏。
```text
config/adminive.json
```
文件保存以下持久化内容:
```text
Application_Config
├── default_device_type
├── radio_service
├── alerts
├── serial_table
└── network_table
```
程序启动时读取该文件。文件不存在时,使用 C++ 默认值自动创建。配置表单点击“确认修改”后,后端先在副本上完成赋值和校验,再写入临时文件并替换正式配置文件,成功后才替换内存对象。
## 多态类型表格
不同表格类型通过 C++ 虚函数注册:
第二个配置示例使用组合条件:
```cpp
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;
};
ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook Endpoint")
.editable()
.visible_on("${enabled && channel == 'webhook'}");
```
示例实现两个子类:
只有启用告警且投递通道为 `webhook` 时,才显示 webhook 子配置。
## 完整输入控件
后端根据 C++ 类型自动选择基础控件:
```text
Serial_Device_Table
Network_Device_Table
std::string -> input-text
整数类型 -> input-number
浮点类型 -> input-number
bool -> switch
enum class -> select
```
界面切换 `device_type` 后,`service.schemaApi` 重新向后端请求对应子类的 Schema
日期和颜色通过后端字段描述指定
```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
serial -> 串口、波特率、校验位、启用
network -> 地址、协议、超时、TLS
Radio State List
Radio Service Configuration
Alert Configuration
```
因此切换类型后,表格列、编辑表单、配置表单和接口路径都会一起改变,前端不需要识别任何业务类型
`Radio Service Configuration` 展示字符串、枚举、整数、浮点数、树状子配置、日期和颜色
`Alert Configuration` 展示布尔开关、字符串、两个枚举、数字、日期、颜色和条件显示的 webhook 子配置。
## 对象状态
对象状态使用独立类型,并通过成员函数指针注册
对象状态使用独立返回类型描述,不混入 CRUD 对象字段
```cpp
struct Radio_Item_Status {
Status_Value<Radio_Operating_State> operating_state;
Status_Value<std::uint64_t> refresh_sequence;
Status_Value<std::string> refresh_time;
};
```
状态值同时携带颜色:
```json
{
"operating_state": {
"value": "running",
"color": "#16a34a"
}
}
```
资源通过成员函数指针注册:
```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/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
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
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
@@ -193,46 +410,20 @@ ctest --test-dir build --output-on-failure
.\build\backend\Adminive_Server.exe 9999
```
访问:
```text
http://127.0.0.1:9999
```
## 源码压缩目标
压缩函数参数顺序:
```cmake
add_project_zip_target(
target_name
output_file
root_directory
include_list_name
exclude_list_name
)
```powershell
cmake --build build --target package_zip
```
调用示例
输出文件位置由第二个参数明确指定,第三个参数传入排除列表变量名
```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)
add_project_zip_target(package_zip "${CMAKE_CURRENT_LIST_DIR}/Adminive.zip" adminive_package_excludes)
```
函数只扫描包含列表指定的目录和文件,然后相对于根目录应用排除列表。