From d289f8d6e26a54496690e776cf0ea039bbe0c8c8 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 8 Aug 2026 16:55:22 +0800 Subject: [PATCH] =?UTF-8?q?94=E5=88=86=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 28 +- backend/library/DESIGN.md | 20 +- backend/library/include/adminive/amis.hpp | 266 +++++++++++------- .../library/include/adminive/composition.hpp | 47 ++++ backend/library/include/adminive/view.hpp | 171 +++++++++++ .../library/include/adminive/view_json.hpp | 93 +++++- backend/service/src/example.hpp | 50 ++++ backend/service/src/example_descriptors.hpp | 120 ++++++++ backend/service/src/gallery.cpp | 177 ++++++++---- backend/service/src/gallery.hpp | 10 +- backend/service/tests/view_schema_test.cpp | 82 +++++- frontend/src/App.css | 237 ++++++++++------ frontend/src/App.tsx | 111 ++++++-- frontend/vite.config.ts | 5 +- 14 files changed, 1115 insertions(+), 302 deletions(-) diff --git a/README.md b/README.md index 9001538..d4f5f8c 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ AMIS Adapter / Frontend ```text Object Schema 结构事实 Field Presentation 单字段呈现 -View Schema Form/Table 语义组合 -Composition View 多组件页面组合 +View Schema Form/Collection 语义组合 +Composition Manifest 多组件页面组合与 Slot Contract ``` -后端保留完整的组合能力,包括 `frontend`、`vertical`、`horizontal`、`flow`、`grid`、`list`、`group`、`card`、`tabs` 和表格。`frontend` 是显式的布局授权节点:后端只声明成员与顺序,最终横排、竖排、网格或响应式位置由前端决定;其余组合节点仍由后端决定语义位置。字段定义不携带表格列顺序/排序等信息,AMIS Adapter 也不承担业务组合决策。 +后端保留完整的组合能力,包括 `frontend`、`vertical`、`horizontal`、`flow`、`grid`、`list`、`group`、`card`、`tabs`,集合展示则由一等 `Collection_View` 表达 `table/list/cards` mode。`frontend` 是显式的布局授权节点:后端只声明成员与顺序,最终横排、竖排、列表、卡片、表格或响应式位置由前端决定;其余组合节点仍由后端决定语义位置。字段定义不携带集合列顺序/排序等信息,AMIS Adapter 也不承担业务组合决策。 例如一个配置对象可以只定义字段事实和单字段控件: @@ -72,14 +72,18 @@ return edit_form(vertical( )); ``` -如果同一个类型还需要列表,表格能力同样单独定义: +如果同一个类型还需要集合展示,先定义统一 Collection View,再只切换展示 mode: ```cpp -return table_view( +const auto base = collection_view( column<&T::host>("Host").search().fix(Table_Fixed::left), column<&T::port>("Port").sort(), column<&T::color>("Color") ).default_sort("port"); +const auto item = collection_item<&T::host>().body<&T::port, &T::color>(); +const auto table = base.as_table(); +const auto list = base.as_list(item); +const auto cards = base.as_cards(item, 3); ``` 其中 `.sort()`、`.search()`、`.filter()` 不只是前端提示:Collection Service 只接受后端 View Schema 明确声明的查询能力。前端不能自行把一个后端不支持的字段变成可排序或可搜索字段。 @@ -94,15 +98,15 @@ const auto page = composition_view("device_page", compose::vertical( )); ``` -`slot` 引用已经生成的 Form/Table/Status 等组件。普通 Composition 节点由后端决定语义组合,AMIS Adapter 只负责翻译;`compose::frontend(...)` 则明确把最终定位权交给前端,同时仍由后端决定页面有哪些组件。这样两种模式可以在同一个项目里并存。 +`slot` 引用已经生成的 Form/Collection/Status 等组件。普通 Composition 节点由后端决定语义组合,AMIS Adapter 只负责翻译;`compose::frontend(...)` 则明确把最终定位权交给前端,同时仍由后端决定页面有哪些组件。自定义前端通过 `adminive.composition-manifest` 同时取得 Composition View 和正式 Slot Contract,Contract 明确组件类型以及 descriptor/view/data/amis 来源,避免前端再维护第二份业务注册表。这样两种模式可以在同一个项目里并存。 ### 示例组件画廊 完整示例首页现在包含“组件画廊”页签。画廊不是前端写死的组件清单,而是后端使用 Adminive 自身 Descriptor、View Schema、Composition View 和 Collection Service 生成,用于直接检查当前支持能力的实际 AMIS 效果。 -字段画廊覆盖 `automatic`、`text`、`multiline_text`、`number`、`boolean`、`select`、`date`、`color`、`std::optional`、只读 static、条件联动和嵌套对象;Form View 同时覆盖 `frontend`、`vertical`、`horizontal`、`grid`、`flow`、`list`、`group`、`card` 和 `tabs`。Collection 使用同一份后端数据和 CRUD 能力提供 `table`、`list`、`cards` 三种 AMIS 展示模式。Composition 画廊覆盖 `slot`、`heading`、`frontend`、`vertical`、`horizontal`、`flow`、`grid`、`list`、`group`、`card`、`tabs` 和 `tab`。 +字段画廊覆盖 `automatic`、`text`、`multiline_text`、`number`、`boolean`、`select`、`date`、`color`、`std::optional`、只读 static、sensitive、条件联动、嵌套对象、Range/Validated Value;Form View 覆盖 display/create/edit 三种 mode 以及 `frontend`、`vertical`、`horizontal`、`grid`、`flow`、`list`、`group`、`card` 和 `tabs`。Collection 使用同一份后端字段、查询和 CRUD 事实,由一等 `Collection_View` 直接表达 `table/list/cards` 三种 mode,不再生成 table AMIS 后二次改写。Composition 画廊覆盖 `slot`、正式 Slot Contract、`heading`、`frontend`、`vertical`、`horizontal`、`flow`、`grid`、`list`、`group`、`card`、`tabs` 和 `tab`。高级能力页提供实际可操作的 Object/Value/Control Adapter、Polymorphic Adapter、Sensitive、Resource Transaction、422 字段错误和 Status Polling Demo。 -React 首页现在本身就是文档与组件画廊:可以切换“后端组合定位”和“前端决定定位”,前端定位页用同一批后端 slot 分别演示横排、竖排,并把同一份 Collection 数据渲染成列表、卡片和表格;协议文档页直接显示真实 Descriptor、Form/Table View、Composition View、AMIS 和数据 JSON。 +React 首页现在本身就是文档与组件画廊:可以切换“后端组合定位”和“前端决定定位”。前端定位页递归读取 Composition View 的 slot 顺序,从 Manifest 的 Slot Contract 取得真实 Form/List/Cards 组件,然后把**同一批组件**分别组合成横排、竖排、列表、卡片和表格;React 不写死 slot 名,也不知道 `Gallery_Row` 的业务字段。活文档页直接并排展示实际效果对应的 C++ 声明、`adminive.view`/Manifest JSON、最终 AMIS JSON、HTTP 接口和真实数据。 画廊资源同时提供正常 Adminive HTTP 接口: @@ -118,16 +122,22 @@ React 首页现在本身就是文档与组件画廊:可以切换“后端组 /admin/gallery/items /admin/gallery/items/{id} /admin/gallery/items/order +/admin/gallery/items/list/view +/admin/gallery/items/cards/view +/admin/gallery/items/table/view /admin/gallery/items/list/amis /admin/gallery/items/cards/amis /admin/gallery/items/table/amis +/admin/gallery/advanced/adapter/{descriptor,view,data,amis} +/admin/gallery/advanced/polymorphic/{descriptor,view,data,amis} +/admin/gallery/advanced/object/{descriptor,view,data,amis} /admin/gallery/backend/view /admin/gallery/backend/amis /admin/gallery/frontend/manifest /admin/gallery/docs ``` -`flow` 在 AMIS Adapter 中翻译成可换行 Flex,每个 `items` 元素都是显式 `container` renderer。`list` 保留条目语义并生成明确的条目容器;`frontend` 在 AMIS 中使用中性竖向 fallback,但原始 View JSON 保留 `kind: "frontend"`,自定义前端可以接管最终位置。静态首页禁止缓存,带内容 hash 的 `/assets/` 和版本固定的 `/vendor/amis/` 使用长期 immutable 缓存,避免浏览器继续持有旧 `index.html` 后引用已经不存在的动态 chunk。 +`flow` 在 AMIS Adapter 中翻译成可换行 Flex,每个 `items` 元素都是显式 `container` renderer。`list` 保留条目语义并生成明确的条目容器;`frontend` 在 AMIS 中使用中性竖向 fallback,但原始 View JSON 保留 `kind: "frontend"`,自定义前端可以接管最终位置。Form 与 Collection 的 AMIS submit API 只映射当前 View 中实际可 `editable`/`creatable` 的字段,readonly 展示数据不会被表单整体回传,后端仍保持“提交不可写字段即 422”的严格语义。静态首页禁止缓存,带内容 hash 的 `/assets/` 和带 AMIS 包版本号的 `/vendor/amis//` 使用长期 immutable 缓存。升级 AMIS 时 URL 会随版本变化,不会让 Edge 等浏览器继续复用旧 SDK;旧 `index.html` 也不会再引用已经不存在的动态 chunk。 ## 后端分层 diff --git a/backend/library/DESIGN.md b/backend/library/DESIGN.md index aea9f8b..19f082c 100644 --- a/backend/library/DESIGN.md +++ b/backend/library/DESIGN.md @@ -240,7 +240,9 @@ tabs / tab 后端因此仍然可以完整决定“这十几个字段如何拼接”,但组合关系不再污染字段定义。一个类型可以分别拥有 Edit/Create/Detail View。 -Table View 是一等视图,而不是普通 Field Presentation 的附属标记。它负责: +Collection View 是集合展示的一等视图。`table/list/cards` 是同一个集合契约的三个 mode,共享字段、查询、CRUD、排序与 create/edit layout;list/cards 的 title/subtitle/body/accent 也属于 `adminive.view` 语义,不允许先生成 table AMIS 再回头修改 renderer JSON。`Table_View` 继续作为 Collection View 内部的表格/查询基础契约,并保留原有 API 语义。 + +Table View 负责: ```text 哪些字段成为列 @@ -276,6 +278,8 @@ tabs / tab `slot` 只是引用一个已经生成的业务组件。例如“上方配置表单 + 下方设备表格”可以由后端描述为一个 vertical composition,而不需要业务代码直接拼 AMIS `grid/card/tpl` JSON。`list` 表示显式的纵向条目集合;`frontend` 则只声明子节点及其顺序,不声明最终布局,让前端根据设备、容器或产品形态决定横排、竖排、列表、卡片或表格等视觉组织。 +当 `frontend` 交给独立前端消费时,只给 Composition View 仍不够,因为 slot 名本身不说明组件从哪里取得。因此正式外部协议使用 `adminive.composition-manifest`:Manifest 同时包含 Composition View 和 Slot Contract registry。每个 contract 可以声明 `component_kind`、`descriptor_api`、`view_api`、`data_api`、`amis_api` 以及当前 renderer schema。后端在 Manifest 边界验证 Composition 中使用的 slot 都有唯一 contract,前端只按 View 顺序和 Contract 渲染,不维护第二份 slot/业务字段表。 + ### 5.5 后端与前端的最终边界 后端负责: @@ -286,8 +290,9 @@ tabs / tab 单字段 presentation 字段属于哪个视图 字段顺序与逻辑分组 -form/table/page 的语义组合 -表格排序、搜索、筛选等后端能力 +form/collection/page 的语义组合 +collection 排序、搜索、筛选等后端能力 +slot contract 与组件来源 ``` 前端负责: @@ -325,7 +330,7 @@ AMIS Adapter AMIS JSON ``` -不能反过来让 `Field_Descriptor` 直接保存任意 AMIS JSON;否则 Adminive 会从 backend-driven schema 退化成“在 C++ 里写前端 JSON”。 +不能反过来让 `Field_Descriptor` 直接保存任意 AMIS JSON;否则 Adminive 会从 backend-driven schema 退化成“在 C++ 里写前端 JSON”。AMIS 表单提交同样只能由 View 与字段权限推导 payload:readonly 字段可以出现在初始展示数据里,但 submit `api.data` 只包含当前 View 选中的 `editable`/`creatable` 字段。这里不能通过放宽 Resource Service 去兼容前端整对象回传,否则会破坏默认只读与 422 拒绝不可写字段的既有安全语义。 ## 6. 默认只读,显式声明可写 @@ -919,9 +924,11 @@ schema validation Field Presentation 与结构事实分离 Form View 的 frontend/group/card/grid/flow/list/tabs 组合 Table View 的列选择与顺序 -Collection 的 table/list/cards 三种 Adapter 结果与独立 CRUD id/reload 目标 +Collection View 的 table/list/cards mode、item 语义和独立 CRUD id/reload 目标 +Form/Collection submit payload 只包含 View 中可 editable/creatable 的字段 sortable/searchable/filterable/fixed 的后端能力约束 Composition View 的 slot、frontend 与跨组件组合 +Composition Manifest 的 Slot Contract 完整性 /view JSON 与 AMIS Adapter 的语义一致性 ``` @@ -943,6 +950,9 @@ JSON encode/decode Enum Adapter Reflection Adapter Object Adapter +Value Adapter +Control Adapter +Polymorphic Adapter HTTP Adapter AMIS Adapter ``` diff --git a/backend/library/include/adminive/amis.hpp b/backend/library/include/adminive/amis.hpp index 6a61a8d..e566198 100644 --- a/backend/library/include/adminive/amis.hpp +++ b/backend/library/include/adminive/amis.hpp @@ -411,6 +411,49 @@ inline std::string_view form_permission(Form_Mode mode) noexcept { } return "readable"; } +inline std::size_t amis_grid_width(std::size_t index, std::size_t count) noexcept { + if(count == 0) { + return 12; + } + if(count >= 12) { + return 1; + } + const std::size_t width = 12 / count; + return width + (index % count < 12 % count ? 1 : 0); +} +inline void collect_amis_submit_fields(const View_Node& node, std::vector& fields, bool& all_fields_selected) { + if(all_fields_selected) { + return; + } + if(node.kind == View_Node_Kind::all_fields) { + all_fields_selected = true; + fields.clear(); + return; + } + if(node.kind == View_Node_Kind::field && std::find(fields.begin(), fields.end(), node.field) == fields.end()) { + fields.push_back(node.field); + } + for(const auto& child : node.children) { + collect_amis_submit_fields(child, fields, all_fields_selected); + } +} +template +Json make_amis_submit_data(const Json& descriptor, const View_Node& body, std::string_view permission) { + std::vector selected_fields; + bool all_fields_selected{}; + collect_amis_submit_fields(body, selected_fields, all_fields_selected); + Json result = json_object(); + const auto& fields = Json_Adapter::at(descriptor, "fields"); + for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { + const auto& field = Json_Adapter::at(fields, index); + const std::string name = json_get(Json_Adapter::at(field, "name")); + const bool selected = all_fields_selected || std::find(selected_fields.begin(), selected_fields.end(), name) != selected_fields.end(); + if(selected && json_get(Json_Adapter::at(field, permission))) { + json_set(result, name, "${" + name + "}"); + } + } + return result; +} template void apply_view_node_options(Json& result, const View_Node& node, std::string_view prefix) { if(!node.description.empty()) { @@ -481,11 +524,10 @@ Json make_amis_view_node(const View_Node& node, const Json& descriptor_json, std if(node.kind == View_Node_Kind::horizontal || node.kind == View_Node_Kind::grid) { Json columns = json_array(); const std::size_t count = node.kind == View_Node_Kind::grid && node.columns != 0 ? node.columns : std::max(1, node.children.size()); - const std::size_t width = std::max(1, 12 / count); - for(const auto& child : node.children) { + for(std::size_t index = 0; index < node.children.size(); ++index) { Json column = json_object(); - json_set(column, "md", width); - json_set(column, "body", make_amis_view_body(child, descriptor_json, permission, prefix)); + json_set(column, "md", amis_grid_width(index, count)); + json_set(column, "body", make_amis_view_body(node.children[index], descriptor_json, permission, prefix)); json_append(columns, std::move(column)); } Json result = json_object(); @@ -650,11 +692,10 @@ Json make_amis_composition_node(const Composition_Node& node, const std::map(); const std::size_t count = node.kind == Composition_Node_Kind::grid && node.columns != 0 ? node.columns : std::max(1, node.children.size()); - const std::size_t width = std::max(1, 12 / count); - for(const auto& child : node.children) { + for(std::size_t index = 0; index < node.children.size(); ++index) { Json column = json_object(); - json_set(column, "md", width); - json_set(column, "body", make_amis_composition_body(child, slots)); + json_set(column, "md", amis_grid_width(index, count)); + json_set(column, "body", make_amis_composition_body(node.children[index], slots)); json_append(columns, std::move(column)); } Json result = json_object(); @@ -844,6 +885,7 @@ Json to_amis_form_schema(const T& value, const Form_View& view, std::string subm Json api = json_object(); json_set(api, "method", "post"); json_set(api, "url", std::move(submit_api)); + json_set(api, "data", make_amis_submit_data(descriptor, view.body, form_permission(view.mode))); json_set(result, "api", std::move(api)); } } @@ -861,45 +903,63 @@ Json to_amis_form_schema(const T& value, const Form_View& view, std::string subm return adminive::to_amis_form_schema(item, view, std::move(submit_api)); }); } +inline std::string collection_field_label(const Collection_View& view, std::string_view field) { + for(const auto& column : view.table.columns) { + if(column.field == field) { + return column.label.empty() ? make_label(field) : column.label; + } + } + return make_label(field); +} +template +Json make_amis_semantic_collection_item(const Collection_View& view) { + Json body = json_array(); + for(const auto& field : view.item.fields) { + Json line = json_object(); + json_set(line, "type", "tpl"); + json_set(line, "tpl", "" + collection_field_label(view, field) + ":${" + field + "}"); + json_append(body, std::move(line)); + } + if(!view.item.accent_field.empty()) { + Json accent = json_object(); + json_set(accent, "type", "tpl"); + json_set(accent, "tpl", "
"); + json_append(body, std::move(accent)); + } + if(view.mode == Collection_Mode::list) { + Json result = json_object(); + json_set(result, "title", "${" + view.item.title_field + "}"); + if(!view.item.subtitle_field.empty()) { + json_set(result, "subTitle", "${" + view.item.subtitle_field + "}"); + } + json_set(result, "body", std::move(body)); + return result; + } + Json header = json_object(); + json_set(header, "title", "${" + view.item.title_field + "}"); + if(!view.item.subtitle_field.empty()) { + json_set(header, "subTitle", "${" + view.item.subtitle_field + "}"); + } + Json result = json_object(); + json_set(result, "header", std::move(header)); + json_set(result, "body", std::move(body)); + return result; +} template requires Described_Type> -Json to_amis_table_schema(std::string base_api, const Table_View& view, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { +Json make_amis_collection_schema(std::string base_api, const Table_View& view, std::string mode, const Json* item, std::size_t cards_columns, const Json* item_status_descriptor, std::uint64_t item_status_interval, const Json* overview_status_descriptor, std::string overview_status_api, std::uint64_t overview_status_interval) { using Model = Object_Model_Type; validate_table_view(view); const Json descriptor = to_descriptor_json(); const auto object_descriptor = describe(); - const std::string crud_id = view.name + "_crud"; - Json columns = json_array(); - Json id_column = json_object(); - json_set(id_column, "name", "id"); - json_set(id_column, "label", view.id_label); - json_set(id_column, "sortable", true); - json_append(columns, std::move(id_column)); - for(const auto& definition : view.columns) { - Json column_json = make_typed_amis_column_by_name(descriptor, object_descriptor, definition.field); - if(!definition.label.empty()) { - json_set(column_json, "label", definition.label); - } - json_set(column_json, "sortable", definition.sortable); - if(definition.searchable) { - json_set(column_json, "searchable", true); - } - if(definition.filterable) { - json_set(column_json, "filterable", true); - } - if(definition.fixed == Table_Fixed::left) { - json_set(column_json, "fixed", "left"); - } else if(definition.fixed == Table_Fixed::right) { - json_set(column_json, "fixed", "right"); - } - json_append(columns, std::move(column_json)); - } - if(item_status_descriptor) { - json_append(columns, make_amis_object_status_column(*item_status_descriptor, base_api + "/${id}/status", item_status_interval, view.view_status_label)); + std::string crud_id = view.name + "_crud"; + if(mode != "table") { + crud_id += "_" + mode; } Json create_api = json_object(); json_set(create_api, "method", "post"); json_set(create_api, "url", base_api); + json_set(create_api, "data", make_amis_submit_data(descriptor, view.create_body, "creatable")); Json create_form = json_object(); json_set(create_form, "type", "form"); json_set(create_form, "api", std::move(create_api)); @@ -909,6 +969,7 @@ Json to_amis_table_schema(std::string base_api, const Table_View& view, const Js Json edit_api = json_object(); json_set(edit_api, "method", "put"); json_set(edit_api, "url", base_api + "/${id}"); + json_set(edit_api, "data", make_amis_submit_data(descriptor, view.edit_body, "editable")); Json edit_form = json_object(); json_set(edit_form, "type", "form"); json_set(edit_form, "api", std::move(edit_api)); @@ -948,16 +1009,13 @@ Json to_amis_table_schema(std::string base_api, const Table_View& view, const Js Json buttons = json_array(); json_append(buttons, std::move(edit_button)); json_append(buttons, std::move(delete_button)); - Json operation = json_object(); - json_set(operation, "type", "operation"); - json_set(operation, "label", view.actions_label); - json_set(operation, "buttons", std::move(buttons)); - json_append(columns, std::move(operation)); - Json toggler = json_object(); - json_set(toggler, "type", "columns-toggler"); - json_set(toggler, "draggable", view.column_reorderable); Json toolbar = json_array(); - json_append(toolbar, std::move(toggler)); + if(mode == "table") { + Json toggler = json_object(); + json_set(toggler, "type", "columns-toggler"); + json_set(toggler, "draggable", view.column_reorderable); + json_append(toolbar, std::move(toggler)); + } json_append(toolbar, "reload"); json_append(toolbar, std::move(create_button)); Json crud_api = json_object(); @@ -970,7 +1028,52 @@ Json to_amis_table_schema(std::string base_api, const Table_View& view, const Js json_set(crud, "syncLocation", false); json_set(crud, "primaryField", "id"); json_set(crud, "headerToolbar", std::move(toolbar)); - json_set(crud, "columns", std::move(columns)); + if(mode == "table") { + Json columns = json_array(); + Json id_column = json_object(); + json_set(id_column, "name", "id"); + json_set(id_column, "label", view.id_label); + json_set(id_column, "sortable", true); + json_append(columns, std::move(id_column)); + for(const auto& definition : view.columns) { + Json column_json = make_typed_amis_column_by_name(descriptor, object_descriptor, definition.field); + if(!definition.label.empty()) { + json_set(column_json, "label", definition.label); + } + json_set(column_json, "sortable", definition.sortable); + if(definition.searchable) { + json_set(column_json, "searchable", true); + } + if(definition.filterable) { + json_set(column_json, "filterable", true); + } + if(definition.fixed == Table_Fixed::left) { + json_set(column_json, "fixed", "left"); + } else if(definition.fixed == Table_Fixed::right) { + json_set(column_json, "fixed", "right"); + } + json_append(columns, std::move(column_json)); + } + if(item_status_descriptor) { + json_append(columns, make_amis_object_status_column(*item_status_descriptor, base_api + "/${id}/status", item_status_interval, view.view_status_label)); + } + Json operation = json_object(); + json_set(operation, "type", "operation"); + json_set(operation, "label", view.actions_label); + json_set(operation, "buttons", buttons); + json_append(columns, std::move(operation)); + json_set(crud, "columns", std::move(columns)); + } else { + Json item_schema = *item; + if(!Json_Adapter::contains(item_schema, "actions")) { + json_set(item_schema, "actions", buttons); + } + json_set(crud, "mode", mode); + json_set(crud, mode == "list" ? "listItem" : "card", std::move(item_schema)); + if(mode == "cards") { + json_set(crud, "columnsCount", cards_columns); + } + } if(!view.default_order_by.empty()) { Json params = json_object(); json_set(params, "orderBy", view.default_order_by); @@ -995,64 +1098,31 @@ Json to_amis_table_schema(std::string base_api, const Table_View& view, const Js json_set(result, "body", std::move(page_body)); return result; } -template -Json amis_collection_actions(const Json& crud) { - const auto& columns = Json_Adapter::at(crud, "columns"); - return Json_Adapter::at(Json_Adapter::at(columns, Json_Adapter::size(columns) - 1), "buttons"); -} -template -void replace_amis_reload_target(Json& value, std::string_view old_target, std::string_view new_target) { - if(Json_Adapter::is_object(value)) { - if(Json_Adapter::contains(value, "reload")) { - auto& reload = Json_Adapter::at(value, "reload"); - if(Json_Adapter::is_string(reload) && json_get(reload) == old_target) { - json_set(value, "reload", new_target); - } - } - for(const auto& key : Json_Adapter::keys(value)) { - replace_amis_reload_target(Json_Adapter::at(value, key), old_target, new_target); - } - return; - } - if(Json_Adapter::is_array(value)) { - for(std::size_t index = 0; index < Json_Adapter::size(value); ++index) { - replace_amis_reload_target(Json_Adapter::at(value, index), old_target, new_target); - } - } -} -template -Json make_amis_collection_variant(Json schema, std::string mode, std::string item_key, Json item, std::size_t columns_count = 0) { - auto& body = Json_Adapter::at(schema, "body"); - auto& crud = Json_Adapter::at(body, Json_Adapter::size(body) - 1); - const std::string old_id = json_get(Json_Adapter::at(crud, "id")); - const std::string id = old_id + "_" + mode; - if(!Json_Adapter::contains(item, "actions")) { - json_set(item, "actions", amis_collection_actions(crud)); - } - const auto& old_toolbar = Json_Adapter::at(crud, "headerToolbar"); - Json toolbar = json_array(); - for(std::size_t index = 1; index < Json_Adapter::size(old_toolbar); ++index) { - json_append(toolbar, Json_Adapter::at(old_toolbar, index)); - } - Json_Adapter::erase(crud, "columns"); - json_set(crud, "id", id); - json_set(crud, "headerToolbar", std::move(toolbar)); - json_set(crud, "mode", std::move(mode)); - json_set(crud, item_key, std::move(item)); - if(columns_count != 0) { - json_set(crud, "columnsCount", columns_count); - } - replace_amis_reload_target(crud, old_id, id); - return schema; +template +requires Described_Type> +Json to_amis_table_schema(std::string base_api, const Table_View& view, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { + return make_amis_collection_schema(std::move(base_api), view, "table", nullptr, 0, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval); } template requires Described_Type> Json to_amis_list_schema(std::string base_api, const Table_View& view, Json list_item, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { - return make_amis_collection_variant(to_amis_table_schema(std::move(base_api), view, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval), "list", "listItem", std::move(list_item)); + return make_amis_collection_schema(std::move(base_api), view, "list", &list_item, 0, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval); } template requires Described_Type> Json to_amis_cards_schema(std::string base_api, const Table_View& view, Json card, std::size_t columns_count = 3, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { - return make_amis_collection_variant(to_amis_table_schema(std::move(base_api), view, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval), "cards", "card", std::move(card), columns_count); + return make_amis_collection_schema(std::move(base_api), view, "cards", &card, columns_count, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval); } +template +requires Described_Type> +Json to_amis_collection_schema(std::string base_api, const Collection_View& view, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { + using Model = Object_Model_Type; + validate_collection_view(view); + if(view.mode == Collection_Mode::table) { + return make_amis_collection_schema(std::move(base_api), view.table, "table", nullptr, 0, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval); + } + Json item = make_amis_semantic_collection_item(view); + return make_amis_collection_schema(std::move(base_api), view.table, std::string(collection_mode_name(view.mode)), &item, view.cards_columns, item_status_descriptor, item_status_interval, overview_status_descriptor, std::move(overview_status_api), overview_status_interval); +} + } diff --git a/backend/library/include/adminive/composition.hpp b/backend/library/include/adminive/composition.hpp index 3267241..b40ba64 100644 --- a/backend/library/include/adminive/composition.hpp +++ b/backend/library/include/adminive/composition.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include #include @@ -137,6 +138,14 @@ struct Composition_View { return result; } }; +struct Composition_Slot_Contract { + std::string name; + std::string component_kind; + std::string descriptor_api; + std::string view_api; + std::string data_api; + std::string amis_api; +}; inline Composition_View composition_view(std::string name, Composition_Node body) { return Composition_View{std::move(name), {}, std::move(body)}; } @@ -170,4 +179,42 @@ inline void validate_composition_view(const Composition_View& view) { } validate_composition_node(view.body); } +inline void collect_composition_slots(const Composition_Node& node, std::vector& result) { + if(node.kind == Composition_Node_Kind::slot) { + result.push_back(node.slot); + } + for(const auto& child : node.children) { + collect_composition_slots(child, result); + } +} +inline void validate_composition_slot_contracts(const Composition_View& view, const std::vector& contracts) { + validate_composition_view(view); + std::vector slots; + collect_composition_slots(view.body, slots); + for(const auto& slot_name : slots) { + const auto iterator = std::find_if(contracts.begin(), contracts.end(), [&](const Composition_Slot_Contract& contract) { + return contract.name == slot_name; + }); + if(iterator == contracts.end()) { + throw std::invalid_argument("composition slot contract is not provided: " + slot_name); + } + } + for(std::size_t index = 0; index < contracts.size(); ++index) { + const auto& contract = contracts[index]; + if(contract.name.empty()) { + throw std::invalid_argument("composition slot contract name must not be empty"); + } + if(contract.component_kind.empty()) { + throw std::invalid_argument("composition slot contract component kind must not be empty: " + contract.name); + } + for(std::size_t other = index + 1; other < contracts.size(); ++other) { + if(contracts[other].name == contract.name) { + throw std::invalid_argument("duplicate composition slot contract: " + contract.name); + } + } + if(std::find(slots.begin(), slots.end(), contract.name) == slots.end()) { + throw std::invalid_argument("composition slot contract is not referenced by view: " + contract.name); + } + } +} } diff --git a/backend/library/include/adminive/view.hpp b/backend/library/include/adminive/view.hpp index 1e33bfb..2c43506 100644 --- a/backend/library/include/adminive/view.hpp +++ b/backend/library/include/adminive/view.hpp @@ -347,6 +347,144 @@ Table_View table_view(Columns&&... columns) { (result.columns.push_back(std::forward(columns)), ...); return result; } +enum class Collection_Mode { + table, + list, + cards +}; +struct Collection_Item_View { + std::string title_field; + std::string subtitle_field; + std::string accent_field; + std::vector fields; + template + Collection_Item_View subtitle() const { + auto result = *this; + result.subtitle_field = described_member_name(); + return result; + } + template + Collection_Item_View accent() const { + auto result = *this; + result.accent_field = described_member_name(); + return result; + } + template + Collection_Item_View body() const { + auto result = *this; + result.fields = {described_member_name()...}; + return result; + } +}; +template +Collection_Item_View collection_item() { + Collection_Item_View result; + result.title_field = described_member_name(); + return result; +} +struct Collection_View { + Table_View table; + Collection_Mode mode{Collection_Mode::table}; + Collection_Item_View item; + std::size_t cards_columns{3}; + Collection_View titled(std::string value) const { + auto result = *this; + result.table.title = std::move(value); + return result; + } + Collection_View id_title(std::string value) const { + auto result = *this; + result.table.id_label = std::move(value); + return result; + } + Collection_View create_title(std::string value) const { + auto result = *this; + result.table.create_label = std::move(value); + return result; + } + Collection_View edit_title(std::string value) const { + auto result = *this; + result.table.edit_label = std::move(value); + return result; + } + Collection_View delete_title(std::string value) const { + auto result = *this; + result.table.delete_label = std::move(value); + return result; + } + Collection_View actions_title(std::string value) const { + auto result = *this; + result.table.actions_label = std::move(value); + return result; + } + Collection_View confirm_title(std::string value) const { + auto result = *this; + result.table.confirm_label = std::move(value); + return result; + } + Collection_View delete_confirmation(std::string value) const { + auto result = *this; + result.table.delete_confirm = std::move(value); + return result; + } + Collection_View status_title(std::string value) const { + auto result = *this; + result.table.view_status_label = std::move(value); + return result; + } + Collection_View default_sort(std::string field_name, bool descending = false) const { + auto result = *this; + result.table.default_order_by = std::move(field_name); + result.table.default_order_dir = descending ? "desc" : "asc"; + return result; + } + Collection_View reorderable(bool value = true) const { + auto result = *this; + result.table.user_reorderable = value; + return result; + } + Collection_View reorderable_columns(bool value = true) const { + auto result = *this; + result.table.column_reorderable = value; + return result; + } + Collection_View create_layout(View_Node value) const { + auto result = *this; + result.table.create_body = std::move(value); + return result; + } + Collection_View edit_layout(View_Node value) const { + auto result = *this; + result.table.edit_body = std::move(value); + return result; + } + Collection_View as_table() const { + auto result = *this; + result.mode = Collection_Mode::table; + result.item = {}; + return result; + } + Collection_View as_list(Collection_Item_View value) const { + auto result = *this; + result.mode = Collection_Mode::list; + result.item = std::move(value); + return result; + } + Collection_View as_cards(Collection_Item_View value, std::size_t columns = 3) const { + auto result = *this; + result.mode = Collection_Mode::cards; + result.item = std::move(value); + result.cards_columns = columns; + return result; + } +}; +template +Collection_View collection_view(Columns&&... columns) { + Collection_View result; + result.table = table_view(std::forward(columns)...); + result.table.name = describe().name() + "_collection"; + return result; +} template struct Type_View_Descriptor; template @@ -381,6 +519,14 @@ template Table_View describe_table_view() requires Table_View_Described_Type { return Type_View_Descriptor::table(); } +template +concept Collection_View_Described_Type = Described_Type && requires { + { Type_View_Descriptor::collection() } -> std::same_as; +}; +template +Collection_View describe_collection_view() requires Collection_View_Described_Type { + return Type_View_Descriptor::collection(); +} template bool descriptor_has_field(const Descriptor& descriptor, std::string_view name) { bool found{}; @@ -457,4 +603,29 @@ void validate_table_view(const Table_View& view) { validate_view_node(view.create_body); validate_view_node(view.edit_body); } +template +void validate_collection_view(const Collection_View& view) { + validate_table_view(view.table); + if(view.mode == Collection_Mode::table) { + return; + } + const auto descriptor = describe(); + if(view.item.title_field.empty() || !descriptor_has_field(descriptor, view.item.title_field)) { + throw std::invalid_argument("collection item title field does not exist: " + view.item.title_field); + } + if(!view.item.subtitle_field.empty() && !descriptor_has_field(descriptor, view.item.subtitle_field)) { + throw std::invalid_argument("collection item subtitle field does not exist: " + view.item.subtitle_field); + } + if(!view.item.accent_field.empty() && !descriptor_has_field(descriptor, view.item.accent_field)) { + throw std::invalid_argument("collection item accent field does not exist: " + view.item.accent_field); + } + for(const auto& field : view.item.fields) { + if(!descriptor_has_field(descriptor, field)) { + throw std::invalid_argument("collection item body field does not exist: " + field); + } + } + if(view.mode == Collection_Mode::cards && view.cards_columns == 0) { + throw std::invalid_argument("collection cards requires at least one column"); + } +} } diff --git a/backend/library/include/adminive/view_json.hpp b/backend/library/include/adminive/view_json.hpp index 5aea5cc..a555aec 100644 --- a/backend/library/include/adminive/view_json.hpp +++ b/backend/library/include/adminive/view_json.hpp @@ -2,6 +2,7 @@ #include "adminive/composition.hpp" #include "adminive/json.hpp" #include "adminive/view.hpp" +#include namespace adminive { inline std::string_view composition_node_kind_name(Composition_Node_Kind value) noexcept { switch(value) { @@ -81,6 +82,39 @@ Json to_view_json(const Composition_View& view) { json_set(result, "body", composition_node_to_json(view.body)); return result; } +template +Json to_composition_manifest_json(const Composition_View& view, const std::vector& contracts, const std::map& schemas) { + validate_composition_slot_contracts(view, contracts); + Json slots = json_object(); + for(const auto& contract : contracts) { + const auto schema = schemas.find(contract.name); + if(schema == schemas.end()) { + throw std::invalid_argument("composition slot schema is not provided: " + contract.name); + } + Json item = json_object(); + json_set(item, "component_kind", contract.component_kind); + if(!contract.descriptor_api.empty()) { + json_set(item, "descriptor_api", contract.descriptor_api); + } + if(!contract.view_api.empty()) { + json_set(item, "view_api", contract.view_api); + } + if(!contract.data_api.empty()) { + json_set(item, "data_api", contract.data_api); + } + if(!contract.amis_api.empty()) { + json_set(item, "amis_api", contract.amis_api); + } + json_set(item, "schema", schema->second); + json_set(slots, contract.name, std::move(item)); + } + Json result = json_object(); + json_set(result, "protocol", "adminive.composition-manifest"); + json_set(result, "protocol_version", 1); + json_set(result, "view", to_view_json(view)); + json_set(result, "slots", std::move(slots)); + return result; +} inline std::string_view view_node_kind_name(View_Node_Kind value) noexcept { switch(value) { case View_Node_Kind::all_fields: @@ -179,13 +213,19 @@ Json to_view_json(const Form_View& view) { json_set(result, "body", view_node_to_json(view.body)); return result; } -template -Json to_view_json(const Table_View& view) { - validate_table_view(view); - Json result = json_object(); - json_set(result, "protocol", "adminive.view"); - json_set(result, "protocol_version", 1); - json_set(result, "kind", "table"); +inline std::string_view collection_mode_name(Collection_Mode value) noexcept { + switch(value) { + case Collection_Mode::table: + return "table"; + case Collection_Mode::list: + return "list"; + case Collection_Mode::cards: + return "cards"; + } + return "table"; +} +template +void append_table_view_json(Json& result, const Table_View& view) { json_set(result, "name", view.name); json_set(result, "title", view.title); json_set(result, "id_label", view.id_label); @@ -216,6 +256,45 @@ Json to_view_json(const Table_View& view) { json_set(result, "columns", std::move(columns)); json_set(result, "create_body", view_node_to_json(view.create_body)); json_set(result, "edit_body", view_node_to_json(view.edit_body)); +} +template +Json to_view_json(const Table_View& view) { + validate_table_view(view); + Json result = json_object(); + json_set(result, "protocol", "adminive.view"); + json_set(result, "protocol_version", 1); + json_set(result, "kind", "table"); + append_table_view_json(result, view); + return result; +} +template +Json to_view_json(const Collection_View& view) { + validate_collection_view(view); + Json result = json_object(); + json_set(result, "protocol", "adminive.view"); + json_set(result, "protocol_version", 1); + json_set(result, "kind", "collection"); + json_set(result, "mode", std::string(collection_mode_name(view.mode))); + append_table_view_json(result, view.table); + if(view.mode != Collection_Mode::table) { + Json item = json_object(); + json_set(item, "title_field", view.item.title_field); + if(!view.item.subtitle_field.empty()) { + json_set(item, "subtitle_field", view.item.subtitle_field); + } + if(!view.item.accent_field.empty()) { + json_set(item, "accent_field", view.item.accent_field); + } + Json fields = json_array(); + for(const auto& field : view.item.fields) { + json_append(fields, field); + } + json_set(item, "fields", std::move(fields)); + json_set(result, "item", std::move(item)); + } + if(view.mode == Collection_Mode::cards) { + json_set(result, "cards_columns", view.cards_columns); + } return result; } } diff --git a/backend/service/src/example.hpp b/backend/service/src/example.hpp index 1d8cfd2..1669100 100644 --- a/backend/service/src/example.hpp +++ b/backend/service/src/example.hpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include namespace adminive::example { using Json = nlohmann::json; template @@ -153,6 +155,54 @@ struct Gallery_Row { bool enabled{true}; std::string color{"#2563eb"}; }; +struct Gallery_Color_Code { + std::string value{"#7c3aed"}; +}; +struct Gallery_Adapter_Config { + Gallery_Color_Code accent; + std::string secret{"gallery-token"}; + std::string visible{"普通字段仍然正常输出"}; +}; +struct Gallery_Serial_Target { + std::string port{"COM7"}; + Range_Value baud_rate{115200}; + Gallery_Color_Code color; +}; +struct Gallery_Network_Target { + std::string host{"127.0.0.1"}; + Range_Value port{9000}; +}; +using Gallery_Target = std::variant; +struct Gallery_Polymorphic_Config { + Gallery_Target target{Gallery_Serial_Target{}}; +}; +struct Gallery_Runtime_Model { + Endpoint_Config endpoint{}; + std::string name{"Object Adapter Runtime"}; + std::uint64_t apply_count{}; + std::uint64_t transaction_commits{}; +}; +class Gallery_Runtime_Config { +public: + Gallery_Runtime_Model snapshot() const { + auto result = model_; + result.apply_count = apply_count_; + result.transaction_commits = transaction_commits_; + return result; + } + void apply(Gallery_Runtime_Model value) { + model_.endpoint = std::move(value.endpoint); + model_.name = std::move(value.name); + ++apply_count_; + } + void transaction_committed() noexcept { + ++transaction_commits_; + } +private: + Gallery_Runtime_Model model_; + std::uint64_t apply_count_{}; + std::uint64_t transaction_commits_{}; +}; struct Serial_Device_Row { std::string port_name; Range_Value baud_rate{115200}; diff --git a/backend/service/src/example_descriptors.hpp b/backend/service/src/example_descriptors.hpp index d6e8c86..269feb4 100644 --- a/backend/service/src/example_descriptors.hpp +++ b/backend/service/src/example_descriptors.hpp @@ -1,7 +1,43 @@ #pragma once #include "example.hpp" +#include namespace adminive { template <> +struct Object_Adapter { + using model_type = example::Gallery_Runtime_Model; + static model_type snapshot(const example::Gallery_Runtime_Config& value) { + return value.snapshot(); + } + static void commit(example::Gallery_Runtime_Config& target, model_type value) { + target.apply(std::move(value)); + } +}; +template +struct Value_Adapter { + using value_type = std::string; + static const std::string& read(const example::Gallery_Color_Code& value) noexcept { + return value.value; + } + static void write(example::Gallery_Color_Code& target, std::string value) { + target.value = std::move(value); + } +}; +template <> +struct Control_Adapter { + static example::Json make_control(const example::Json& field, Control_Context context) { + example::Json result; + result["type"] = "input-color"; + result["name"] = make_field_name(context.prefix, field.at("name").get()); + result["label"] = field.at("presentation").at("label"); + result["clearable"] = true; + return result; + } + static example::Json make_column(const example::Json& field) { + const std::string name = field.at("name").get(); + return example::Json{{"type", "tpl"}, {"name", name}, {"label", field.at("presentation").at("label")}, {"tpl", "${" + name + "}"}}; + } +}; +template <> struct Type_Descriptor { static auto get() { using T = example::Server_Status; @@ -112,6 +148,90 @@ struct Type_View_Descriptor { using T = example::Gallery_Row; return table_view(column<&T::name>("名称").sort().search().fix(Table_Fixed::left), column<&T::category>("分类").sort().filter(), column<&T::score>("评分").sort(), column<&T::enabled>("启用").sort().filter(), column<&T::color>("颜色")).titled("表格与操作画廊").id_title("编号").create_title("新增一行").edit_title("编辑").delete_title("删除").actions_title("操作").confirm_title("保存修改").delete_confirmation("确定删除这条画廊数据吗?").default_sort("score", true).reorderable(); } + static Collection_View collection() { + Collection_View result; + result.table = table(); + result.table.name = "gallery_row_collection"; + result.table.title = "同一 Collection View 的三种展示"; + return result; + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Gallery_Runtime_Model; + return object("gallery_runtime_model", ADMINIVE_FIELD_LABEL(T, endpoint, "运行端点").editable(), ADMINIVE_FIELD_LABEL(T, name, "运行名称").editable().required(), ADMINIVE_FIELD_LABEL(T, apply_count, "运行时应用次数"), ADMINIVE_FIELD_LABEL(T, transaction_commits, "事务提交次数")).label("Object Adapter 运行时"); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = example::Gallery_Runtime_Model; + return edit_form(vertical(use<&T::name>(), card("嵌套运行端点", use<&T::endpoint>()), horizontal(use<&T::apply_count>(), use<&T::transaction_commits>()))).titled("Object Adapter + Resource Transaction").submit("应用 Runtime Model"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Gallery_Adapter_Config; + return object("gallery_adapter_config", ADMINIVE_FIELD_LABEL(T, accent, "自定义颜色类型").editable().description("Value_Adapter 负责值映射,Control_Adapter 负责 AMIS 控件"), ADMINIVE_FIELD_LABEL(T, secret, "敏感字段").editable().sensitive().description("sensitive 字段不会输出到前端 data"), ADMINIVE_FIELD_LABEL(T, visible, "普通字段").editable()).label("Value / Control Adapter 与 Sensitive"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Gallery_Serial_Target; + return object("gallery_serial_target", ADMINIVE_FIELD_LABEL(T, port, "串口").editable().creatable().required(), ADMINIVE_FIELD_LABEL(T, baud_rate, "波特率").editable().creatable().required(), ADMINIVE_FIELD_LABEL(T, color, "标记颜色").editable().creatable()).label("串口目标"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Gallery_Network_Target; + return object("gallery_network_target", ADMINIVE_FIELD_LABEL(T, host, "主机").editable().creatable().required(), ADMINIVE_FIELD_LABEL(T, port, "端口").editable().creatable().required()).label("网络目标"); + } +}; +template <> +struct Polymorphic_Adapter { + static constexpr std::string_view discriminator() noexcept { + return "type"; + } + static constexpr std::string_view discriminator_label() noexcept { + return "目标类型"; + } + static auto variants() { + return std::tuple( + polymorphic_variant("serial", "串口目标"), + polymorphic_variant("network", "网络目标") + ); + } + static example::Json encode(const example::Gallery_Target& value) { + return std::visit([](const auto& target) { + example::Json result = to_json(target); + using T = std::remove_cvref_t; + result["type"] = std::same_as ? "serial" : "network"; + return result; + }, value); + } + static void decode(example::Gallery_Target& target, const example::Json& value, Write_Context context) { + const std::string type = value.at("type").get(); + if(type == "serial") { + decode_polymorphic_alternative(target, value, context); + return; + } + if(type == "network") { + decode_polymorphic_alternative(target, value, context); + return; + } + throw Field_Validation_Error("type", "unknown target type"); + } +}; +template <> +struct Type_Descriptor { + static auto get() { + using T = example::Gallery_Polymorphic_Config; + return object("gallery_polymorphic_config", ADMINIVE_FIELD_LABEL(T, target, "多态目标").editable()).label("Polymorphic Adapter"); + } }; template <> struct Type_Descriptor { diff --git a/backend/service/src/gallery.cpp b/backend/service/src/gallery.cpp index 37c04b0..adcab92 100644 --- a/backend/service/src/gallery.cpp +++ b/backend/service/src/gallery.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace adminive::example { namespace { Json make_gallery_note(std::string title, std::string text) { @@ -18,25 +19,27 @@ Composition_View make_backend_layout_view() { compose::group("卡片 + 网格", compose::grid(3, compose::card("Card A", compose::slot("card_a")), compose::card("Card B", compose::slot("card_b")), compose::card("Card C", compose::slot("card_c")))), compose::group("Flow 自动换行", compose::flow(compose::card("Flow A", compose::slot("flow_a")), compose::card("Flow B", compose::slot("flow_b")), compose::card("Flow C", compose::slot("flow_c")), compose::card("Flow D", compose::slot("flow_d")))).collapsible_when(), compose::group("Tabs 嵌套", compose::tabs(compose::tab("说明", compose::slot("tabs_a")), compose::tab("嵌套卡片", compose::card("Tab Card", compose::slot("tabs_b"))))))), - compose::tab("列表 / 卡片 / 表格", compose::vertical(compose::heading("同一 Collection 的三种展示"), compose::tabs(compose::tab("列表", compose::slot("collection_list")), compose::tab("卡片", compose::slot("collection_cards")), compose::tab("表格", compose::slot("collection_table"))))), - compose::tab("前端决定定位", compose::frontend(compose::slot("frontend_a"), compose::slot("frontend_b"), compose::slot("frontend_c")).described("frontend 节点只声明组件成员与顺序,具体横排、竖排、网格、列表由前端决定。")) + compose::tab("列表 / 卡片 / 表格", compose::vertical(compose::heading("同一 Collection View 的三种展示"), compose::tabs(compose::tab("列表", compose::slot("collection_list")), compose::tab("卡片", compose::slot("collection_cards")), compose::tab("表格", compose::slot("collection_table"))))), + compose::tab("高级 Adapter", compose::grid(3, compose::card("Value / Control / Sensitive", compose::slot("advanced_adapter")), compose::card("Polymorphic", compose::slot("advanced_polymorphic")), compose::card("Object Adapter", compose::slot("advanced_object")))), + compose::tab("前端决定定位", compose::frontend(compose::slot("frontend_a"), compose::slot("frontend_b"), compose::slot("frontend_c")).described("frontend 节点只声明组件成员与顺序,具体横排、竖排、列表、卡片或表格由前端决定。")) )).titled("Adminive 组件画廊"); } Composition_View make_frontend_layout_view() { return composition_view("frontend_owned_layout", compose::frontend(compose::slot("alpha"), compose::slot("beta"), compose::slot("gamma"))).titled("前端定位演示"); } -Json make_list_item() { - Json body = Json::array(); - body.push_back(Json{{"type", "tpl"}, {"tpl", "评分:${score} 启用:${enabled}"}}); - body.push_back(Json{{"type", "tpl"}, {"tpl", "颜色: ${color}"}}); - return Json{{"title", "${name}"}, {"subTitle", "分类:${category}"}, {"body", std::move(body)}}; +Collection_Item_View make_gallery_item_view() { + using T = Gallery_Row; + return collection_item<&T::name>().subtitle<&T::category>().accent<&T::color>().body<&T::score, &T::enabled, &T::color>(); } -Json make_card() { - Json body = Json::array(); - body.push_back(Json{{"type", "tpl"}, {"tpl", "
评分${score}
"}}); - body.push_back(Json{{"type", "tpl"}, {"tpl", "
启用${enabled}
"}}); - body.push_back(Json{{"type", "tpl"}, {"tpl", "
"}}); - return Json{{"header", Json{{"title", "${name}"}, {"subTitle", "${category}"}, {"description", "Collection cards 模式"}}}, {"body", std::move(body)}}; +Collection_View make_gallery_collection_view(Collection_Mode mode) { + auto result = describe_collection_view(); + if(mode == Collection_Mode::list) { + return result.as_list(make_gallery_item_view()); + } + if(mode == Collection_Mode::cards) { + return result.as_cards(make_gallery_item_view(), 3); + } + return result.as_table(); } Json string_array(std::initializer_list values) { Json result = Json::array(); @@ -45,22 +48,24 @@ Json string_array(std::initializer_list values) { } return result; } +Json make_doc_case(std::string id, std::string title, std::string summary, std::string cpp, Json view, Json amis, Json preview) { + return Json{{"id", std::move(id)}, {"title", std::move(title)}, {"summary", std::move(summary)}, {"cpp", std::move(cpp)}, {"view", std::move(view)}, {"amis", std::move(amis)}, {"preview", std::move(preview)}}; } -Component_Gallery::Component_Gallery() : config_resource_(config_, "/admin/gallery/config"), rows_("/admin/gallery/items", make_rows()) {} +} +Component_Gallery::Component_Gallery() : config_resource_(config_, "/admin/gallery/config"), rows_("/admin/gallery/items", make_rows()), adapter_resource_(adapter_config_, "/admin/gallery/advanced/adapter"), polymorphic_resource_(polymorphic_config_, "/admin/gallery/advanced/polymorphic"), runtime_resource_(runtime_config_, "/admin/gallery/advanced/object", make_resource_transaction([this](const Gallery_Runtime_Model&, const Request_Context&) { runtime_config_.transaction_committed(); })) {} Json Component_Gallery::amis_schema() const { const Json form = config_resource_.amis_schema(); - const Json table = rows_.amis_schema(); const auto composition = make_backend_layout_view(); std::map slots; slots.emplace("form", form); slots.emplace("horizontal_a", make_gallery_note("horizontal", "后端明确声明横向组合,Adapter 翻译为 AMIS grid。")); - slots.emplace("horizontal_b", make_gallery_note("slot", "Slot 可以放 Form、Table、Status 或任意已生成组件。")); - slots.emplace("horizontal_c", make_gallery_note("responsive", "后端只给语义,具体断点仍由 Adapter / 前端实现。")); + slots.emplace("horizontal_b", make_gallery_note("slot", "Slot 可以放 Form、Collection、Status 或任意已生成组件。")); + slots.emplace("horizontal_c", make_gallery_note("responsive", "后端声明组合语义,Adapter 负责断点和具体 renderer。")); slots.emplace("vertical_a", make_gallery_note("vertical", "竖向只表达先后顺序。")); slots.emplace("vertical_b", make_gallery_note("group", "Group 支持标题、折叠和继续嵌套。")); slots.emplace("vertical_c", make_gallery_note("heading", "Heading 可以带强调色。")); slots.emplace("list_a", make_gallery_note("list / 1", "列表节点把多个业务组件表达为线性条目。")); - slots.emplace("list_b", make_gallery_note("list / 2", "列表与 vertical 不同,它保留“条目”语义。")); + slots.emplace("list_b", make_gallery_note("list / 2", "列表与 vertical 不同,它保留条目语义。")); slots.emplace("list_c", make_gallery_note("list / 3", "AMIS Adapter 给条目添加明确的视觉分隔。")); slots.emplace("card_a", make_gallery_note("card", "Card 是组合容器。")); slots.emplace("card_b", make_gallery_note("grid", "Grid 由后端声明逻辑列数。")); @@ -71,11 +76,14 @@ Json Component_Gallery::amis_schema() const { slots.emplace("flow_d", make_gallery_note("flow", "窗口变窄时自动换行。")); slots.emplace("tabs_a", make_gallery_note("tabs", "Tabs 是一等组合节点。")); slots.emplace("tabs_b", make_gallery_note("tab", "Tab 只能作为 Tabs 的直接子节点,验证阶段会拒绝非法位置。")); - slots.emplace("collection_list", list_schema().at("body")); - slots.emplace("collection_cards", cards_schema().at("body")); - slots.emplace("collection_table", table.at("body")); - slots.emplace("frontend_a", make_gallery_note("frontend", "后端只声明成员和顺序,不声明横向、竖向或网格。")); - slots.emplace("frontend_b", make_gallery_note("React / CSS", "前端画廊会把同一批组件分别排成横向、竖向、列表、卡片和表格。")); + slots.emplace("collection_list", collection_schema(Collection_Mode::list).at("body")); + slots.emplace("collection_cards", collection_schema(Collection_Mode::cards).at("body")); + slots.emplace("collection_table", collection_schema(Collection_Mode::table).at("body")); + slots.emplace("advanced_adapter", adapter_resource_.amis_schema()); + slots.emplace("advanced_polymorphic", polymorphic_resource_.amis_schema()); + slots.emplace("advanced_object", runtime_resource_.amis_schema()); + slots.emplace("frontend_a", make_gallery_note("frontend", "后端只声明成员和顺序,不声明横向、竖向、列表、卡片或表格。")); + slots.emplace("frontend_b", make_gallery_note("slot registry", "manifest 明确给出每个 slot 的类型、来源接口和 renderer schema。")); slots.emplace("frontend_c", make_gallery_note("fallback", "AMIS Adapter 对 frontend 节点提供中性的竖向 fallback,不偷做业务布局决策。")); return to_amis_composition_schema(composition, slots); } @@ -83,54 +91,110 @@ Json Component_Gallery::backend_layout_view_json() const { return to_view_json(make_backend_layout_view()); } Json Component_Gallery::frontend_layout_manifest() const { - Json slots = Json::object(); - slots["alpha"] = make_gallery_note("Alpha slot", "后端只声明这个组件属于页面,不指定它在左、右、上、下。具体定位由 React / CSS 决定。"); - slots["beta"] = make_gallery_note("Beta slot", "同一个 slot 集合可以在桌面横排,在窄屏竖排,也可以由业务前端切换布局模式。"); - slots["gamma"] = make_gallery_note("Gamma slot", "字段、校验、数据接口仍由后端 Descriptor / View 驱动,前端只接管视觉位置。"); - return Json{{"view", to_view_json(make_frontend_layout_view())}, {"slots", std::move(slots)}, {"items", rows_.items_json()}}; + const auto view = make_frontend_layout_view(); + const std::vector contracts{ + {"alpha", "amis_schema", "/admin/gallery/config/descriptor", "/admin/gallery/config/view", "/admin/gallery/config/data", "/admin/gallery/config/amis"}, + {"beta", "amis_schema", "/admin/gallery/items/descriptor", "/admin/gallery/items/list/view", "/admin/gallery/items", "/admin/gallery/items/list/amis"}, + {"gamma", "amis_schema", "/admin/gallery/items/descriptor", "/admin/gallery/items/cards/view", "/admin/gallery/items", "/admin/gallery/items/cards/amis"} + }; + const std::map schemas{ + {"alpha", config_resource_.amis_schema()}, + {"beta", collection_schema(Collection_Mode::list)}, + {"gamma", collection_schema(Collection_Mode::cards)} + }; + return to_composition_manifest_json(view, contracts, schemas); } -Json Component_Gallery::list_schema() const { - return to_amis_list_schema("/admin/gallery/items", describe_table_view(), make_list_item()); +Json Component_Gallery::collection_view_json(Collection_Mode mode) const { + return to_view_json(make_gallery_collection_view(mode)); } -Json Component_Gallery::cards_schema() const { - return to_amis_cards_schema("/admin/gallery/items", describe_table_view(), make_card(), 3); +Json Component_Gallery::collection_schema(Collection_Mode mode) const { + return to_amis_collection_schema("/admin/gallery/items", make_gallery_collection_view(mode)); } Json Component_Gallery::documentation() const { - Json config_data; + Gallery_Config config_value; config_.read([&](const Gallery_Config& value) { - config_data = to_frontend_json(value); + config_value = value; }); + const Form_View display_view = detail_view(); + const Form_View create_view = create_form(); + const Form_View edit_view = describe_edit_view(); + Gallery_Config invalid_config = config_value; + const Update_Result invalid_update = apply_frontend_patch(invalid_config, Json{{"even_number", 3}}); + const Json validation_error = make_http_error(422, invalid_update).body; + const Json status_descriptor = to_status_descriptor_json(); + const Json status_amis = make_amis_status_service(status_descriptor, "/admin/status", 2000); Json capabilities = Json::object(); - capabilities["field_controls"] = string_array({"automatic", "text", "multiline_text", "number", "boolean", "select", "date", "color", "optional", "readonly", "visible_on", "nested_object", "range", "validated_value"}); - capabilities["form_layout"] = string_array({"all_fields", "field", "frontend", "vertical", "horizontal", "flow", "grid", "list", "group", "card", "tabs", "tab"}); - capabilities["composition"] = string_array({"slot", "heading", "frontend", "vertical", "horizontal", "flow", "grid", "list", "group", "card", "tabs", "tab"}); - capabilities["collection"] = string_array({"server_paging", "sort", "search", "filter", "create", "edit", "delete", "row_reorder", "column_reorder", "table", "list", "cards"}); - capabilities["runtime"] = string_array({"descriptor", "view", "data", "amis", "managed_update", "validation_errors", "status_polling", "resource_transaction"}); - Json endpoints = string_array({"/admin/amis", "/admin/gallery/config/descriptor", "/admin/gallery/config/view", "/admin/gallery/config/data", "/admin/gallery/config/amis", "/admin/gallery/items/descriptor", "/admin/gallery/items/view", "/admin/gallery/items/amis", "/admin/gallery/items", "/admin/gallery/items/{id}", "/admin/gallery/items/order", "/admin/gallery/items/list/amis", "/admin/gallery/items/cards/amis", "/admin/gallery/items/table/amis", "/admin/gallery/backend/view", "/admin/gallery/backend/amis", "/admin/gallery/frontend/manifest", "/admin/gallery/docs"}); + capabilities["field_controls"] = string_array({"automatic", "text", "multiline_text", "number", "boolean", "select", "date", "color", "optional", "readonly", "sensitive", "visible_on", "nested_object", "range", "validated_value"}); + capabilities["form_layout"] = string_array({"display", "create", "edit", "all_fields", "field", "frontend", "vertical", "horizontal", "flow", "grid", "list", "group", "card", "tabs", "tab"}); + capabilities["composition"] = string_array({"slot", "slot_contract", "composition_manifest", "heading", "frontend", "vertical", "horizontal", "flow", "grid", "list", "group", "card", "tabs", "tab"}); + capabilities["collection"] = string_array({"collection_view", "table", "list", "cards", "server_paging", "sort", "search", "filter", "create", "edit", "delete", "row_reorder", "column_reorder"}); + capabilities["adapter"] = string_array({"object_adapter", "value_adapter", "control_adapter", "polymorphic_adapter"}); + capabilities["runtime"] = string_array({"descriptor", "view", "data", "amis", "managed_update", "validation_422", "status_polling", "resource_transaction", "sensitive_output_filter", "writable_submit_filter", "item_status", "overview_status"}); + Json endpoints = string_array({"/admin/amis", "/admin/status", "/admin/gallery/config/descriptor", "/admin/gallery/config/view", "/admin/gallery/config/data", "/admin/gallery/config/amis", "/admin/gallery/items/descriptor", "/admin/gallery/items/view", "/admin/gallery/items", "/admin/gallery/items/{id}", "/admin/gallery/items/order", "/admin/gallery/items/list/view", "/admin/gallery/items/cards/view", "/admin/gallery/items/table/view", "/admin/gallery/items/list/amis", "/admin/gallery/items/cards/amis", "/admin/gallery/items/table/amis", "/admin/gallery/advanced/adapter/descriptor", "/admin/gallery/advanced/adapter/view", "/admin/gallery/advanced/adapter/data", "/admin/gallery/advanced/adapter/amis", "/admin/gallery/advanced/polymorphic/descriptor", "/admin/gallery/advanced/polymorphic/view", "/admin/gallery/advanced/polymorphic/data", "/admin/gallery/advanced/polymorphic/amis", "/admin/gallery/advanced/object/descriptor", "/admin/gallery/advanced/object/view", "/admin/gallery/advanced/object/data", "/admin/gallery/advanced/object/amis", "/admin/gallery/backend/view", "/admin/gallery/backend/amis", "/admin/gallery/frontend/manifest", "/admin/gallery/docs"}); Json examples = Json::object(); examples["descriptor"] = to_descriptor_json(); examples["form_view"] = config_resource_.view_schema(); - examples["table_view"] = rows_.view_schema(); + examples["form_display_view"] = to_view_json(display_view); + examples["form_create_view"] = to_view_json(create_view); + examples["form_edit_view"] = to_view_json(edit_view); + examples["form_display_amis"] = to_amis_form_schema(config_value, display_view); + examples["form_create_amis"] = to_amis_form_schema(Gallery_Config{}, create_view); + examples["form_edit_amis"] = config_resource_.amis_schema(); + examples["validation_422"] = validation_error; + examples["legacy_table_view"] = rows_.view_schema(); + examples["collection_list_view"] = collection_view_json(Collection_Mode::list); + examples["collection_cards_view"] = collection_view_json(Collection_Mode::cards); + examples["collection_table_view"] = collection_view_json(Collection_Mode::table); examples["backend_composition_view"] = backend_layout_view_json(); - examples["frontend_composition_view"] = to_view_json(make_frontend_layout_view()); + examples["frontend_manifest"] = frontend_layout_manifest(); examples["form_amis"] = config_resource_.amis_schema(); examples["backend_amis"] = amis_schema(); - examples["list_amis"] = list_schema(); - examples["cards_amis"] = cards_schema(); - examples["table_amis"] = rows_.amis_schema(); - examples["data"] = std::move(config_data); + examples["list_amis"] = collection_schema(Collection_Mode::list); + examples["cards_amis"] = collection_schema(Collection_Mode::cards); + examples["table_amis"] = collection_schema(Collection_Mode::table); + examples["adapter_descriptor"] = to_descriptor_json(); + examples["adapter_view"] = adapter_resource_.view_schema(); + examples["adapter_data"] = to_frontend_json(adapter_config_); + examples["adapter_amis"] = adapter_resource_.amis_schema(); + examples["polymorphic_descriptor"] = to_descriptor_json(); + examples["polymorphic_view"] = polymorphic_resource_.view_schema(); + examples["polymorphic_data"] = to_frontend_json(polymorphic_config_); + examples["polymorphic_amis"] = polymorphic_resource_.amis_schema(); + examples["object_adapter_descriptor"] = to_descriptor_json(); + examples["object_adapter_view"] = runtime_resource_.view_schema(); + examples["object_adapter_data"] = to_frontend_json(runtime_config_); + examples["object_adapter_amis"] = runtime_resource_.amis_schema(); + examples["status_descriptor"] = status_descriptor; + examples["status_amis"] = status_amis; + examples["data"] = to_frontend_json(config_value); examples["items"] = rows_.items_json(); + Json cases = Json::array(); + cases.push_back(make_doc_case("form-layout", "Form View:字段与组合布局", "字段事实来自 Descriptor,Form View 只负责字段选择、顺序与组合。", "edit_form(vertical(group(...), grid(...), flow(...), tabs(...)))", config_resource_.view_schema(), config_resource_.amis_schema(), config_resource_.amis_schema())); + cases.push_back(make_doc_case("form-modes", "Form Mode:display / create / edit", "三种模式使用同一 Descriptor,但根据字段权限和 mode 生成不同 View 与 AMIS。", "detail_view(); create_form(); describe_edit_view();", Json{{"display", examples["form_display_view"]}, {"create", examples["form_create_view"]}, {"edit", examples["form_edit_view"]}}, Json{{"display", examples["form_display_amis"]}, {"create", examples["form_create_amis"]}, {"edit", examples["form_edit_amis"]}}, examples["form_edit_amis"])); + cases.push_back(make_doc_case("backend-composition", "Composition:后端决定定位", "horizontal / vertical / list / grid / card / flow / tabs 都由后端 Composition JSON 明确表达。", "composition_view(\"component_gallery\", compose::tabs(...))", backend_layout_view_json(), amis_schema(), amis_schema())); + cases.push_back(make_doc_case("frontend-composition", "Composition Manifest:前端决定定位", "frontend 节点只保留成员与顺序;slot registry 明确每个组件的类型和来源,前端可以用同一批真实组件做五种布局。", "to_composition_manifest_json(view, contracts, schemas)", frontend_layout_manifest(), Json{{"note", "最终布局由 React renderer 决定,manifest 内每个 slot 的 schema 与 amis_api 对应真实组件。"}}, make_gallery_note("前端定位 Preview", "同一 manifest 的五种真实布局在前端定位页由 React renderer 展示。"))); + cases.push_back(make_doc_case("collection-list", "Collection View:列表", "list 是 adminive.view 的一等 Collection mode,不再从 table AMIS JSON 二次改写。", "describe_collection_view().as_list(collection_item<&T::name>().subtitle<&T::category>().body<...>())", collection_view_json(Collection_Mode::list), collection_schema(Collection_Mode::list), collection_schema(Collection_Mode::list))); + cases.push_back(make_doc_case("collection-cards", "Collection View:卡片", "cards 与 table/list 共用字段、查询、CRUD 和排序事实,只改变 Collection View mode。", "describe_collection_view().as_cards(item, 3)", collection_view_json(Collection_Mode::cards), collection_schema(Collection_Mode::cards), collection_schema(Collection_Mode::cards))); + cases.push_back(make_doc_case("collection-table", "Collection View:表格", "table 同样由 Collection View 表达,CRUD id 和 reload 目标由 mode 在生成阶段直接确定。", "describe_collection_view().as_table()", collection_view_json(Collection_Mode::table), collection_schema(Collection_Mode::table), collection_schema(Collection_Mode::table))); + cases.push_back(make_doc_case("value-control-sensitive", "Value / Control Adapter + Sensitive", "自定义 C++ 值类型通过 Value_Adapter 映射数据,通过 Control_Adapter 决定控件;sensitive 字段不会进入 frontend data。", "Value_Adapter; Control_Adapter; field.sensitive()", Json{{"descriptor", examples["adapter_descriptor"]}, {"frontend_data", examples["adapter_data"]}}, examples["adapter_amis"], examples["adapter_amis"])); + cases.push_back(make_doc_case("polymorphic", "Polymorphic Adapter", "variant 的 discriminator、可选类型和每个子类型 Descriptor 都进入协议,AMIS 自动生成类型切换与子表单。", "Polymorphic_Adapter::variants()", examples["polymorphic_descriptor"], examples["polymorphic_amis"], examples["polymorphic_amis"])); + cases.push_back(make_doc_case("object-transaction", "Object Adapter + Resource Transaction", "运行时对象通过 Object_Adapter snapshot/commit 映射到可描述 Model;POST 更新后实际执行 Resource_Transaction,页面上的提交计数会变化。", "Object_Adapter; make_resource_transaction(...)", examples["object_adapter_view"], examples["object_adapter_amis"], examples["object_adapter_amis"])); + cases.push_back(make_doc_case("validation", "422 字段校验", "Validated_Value 的失败会被归并到标准 field_errors,AMIS 可以直接消费字段级错误。", "apply_frontend_patch(value, Json{{\"even_number\", 3}})", Json{{"request", Json{{"even_number", 3}}}, {"response", validation_error}}, examples["form_edit_amis"], examples["form_edit_amis"])); + cases.push_back(make_doc_case("status", "Status Polling", "状态 Descriptor 与轮询 renderer 分离;service renderer 只消费状态接口与轮询周期。", "make_amis_status_service(to_status_descriptor_json(), \"/admin/status\", 2000)", status_descriptor, status_amis, status_amis)); Json layers = Json::array(); layers.push_back(Json{{"name", "Object Schema"}, {"purpose", "字段结构、读写能力、约束与同步事实"}}); layers.push_back(Json{{"name", "Field Presentation"}, {"purpose", "单字段 label、description、control、options、visible_on"}}); - layers.push_back(Json{{"name", "View Schema"}, {"purpose", "Form/Table 字段选择、顺序、查询能力与字段组合"}}); - layers.push_back(Json{{"name", "Composition View"}, {"purpose", "Form/Table/Status 等完整组件之间的页面组合"}}); + layers.push_back(Json{{"name", "View Schema"}, {"purpose", "Form/Collection 字段选择、顺序、查询能力与字段组合"}}); + layers.push_back(Json{{"name", "Composition Manifest"}, {"purpose", "跨组件组合、slot contract 以及 frontend 布局授权"}}); layers.push_back(Json{{"name", "Adapter / Frontend"}, {"purpose", "AMIS renderer、CSS、断点以及 frontend 节点的最终定位"}}); - return Json{{"title", "Adminive 前端内置文档"}, {"summary", "前端展示真实 Descriptor、View、Composition、AMIS 和数据 JSON;所有示例都来自当前后端定义。"}, {"layers", std::move(layers)}, {"capabilities", std::move(capabilities)}, {"endpoints", std::move(endpoints)}, {"examples", std::move(examples)}}; + return Json{{"title", "Adminive 前端内置文档"}, {"summary", "前端展示真实 Descriptor、Form/Collection View、Composition Manifest、Adapter、状态、校验、AMIS 和数据 JSON;示例直接来自当前后端定义。"}, {"layers", std::move(layers)}, {"capabilities", std::move(capabilities)}, {"endpoints", std::move(endpoints)}, {"examples", std::move(examples)}, {"cases", std::move(cases)}}; } void Component_Gallery::bind(httplib::Server& server) { config_resource_.bind(server); rows_.bind(server); + adapter_resource_.bind(server); + polymorphic_resource_.bind(server); + runtime_resource_.bind(server); server.Get("/admin/gallery/backend/view", [this](const httplib::Request&, httplib::Response& response) { write_http_response(response, make_http_success(backend_layout_view_json())); }); @@ -140,14 +204,23 @@ void Component_Gallery::bind(httplib::Server& server) { server.Get("/admin/gallery/frontend/manifest", [this](const httplib::Request&, httplib::Response& response) { write_http_response(response, make_http_success(frontend_layout_manifest())); }); + server.Get("/admin/gallery/items/list/view", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(collection_view_json(Collection_Mode::list))); + }); + server.Get("/admin/gallery/items/cards/view", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(collection_view_json(Collection_Mode::cards))); + }); + server.Get("/admin/gallery/items/table/view", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(collection_view_json(Collection_Mode::table))); + }); server.Get("/admin/gallery/items/list/amis", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, make_http_success(list_schema())); + write_http_response(response, make_http_success(collection_schema(Collection_Mode::list))); }); server.Get("/admin/gallery/items/cards/amis", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, make_http_success(cards_schema())); + write_http_response(response, make_http_success(collection_schema(Collection_Mode::cards))); }); server.Get("/admin/gallery/items/table/amis", [this](const httplib::Request&, httplib::Response& response) { - write_http_response(response, make_http_success(rows_.amis_schema())); + write_http_response(response, make_http_success(collection_schema(Collection_Mode::table))); }); server.Get("/admin/gallery/docs", [this](const httplib::Request&, httplib::Response& response) { write_http_response(response, make_http_success(documentation())); diff --git a/backend/service/src/gallery.hpp b/backend/service/src/gallery.hpp index 9bf3ac1..e1d18a4 100644 --- a/backend/service/src/gallery.hpp +++ b/backend/service/src/gallery.hpp @@ -11,11 +11,17 @@ private: static std::vector make_rows(); Json backend_layout_view_json() const; Json frontend_layout_manifest() const; - Json list_schema() const; - Json cards_schema() const; + Json collection_view_json(Collection_Mode mode) const; + Json collection_schema(Collection_Mode mode) const; Json documentation() const; Managed_Value config_; + Gallery_Adapter_Config adapter_config_; + Gallery_Polymorphic_Config polymorphic_config_; + Gallery_Runtime_Config runtime_config_; Http_Resource config_resource_; Http_Collection_Resource rows_; + Http_Resource adapter_resource_; + Http_Resource polymorphic_resource_; + Http_Resource runtime_resource_; }; } diff --git a/backend/service/tests/view_schema_test.cpp b/backend/service/tests/view_schema_test.cpp index d69369c..2a48b2d 100644 --- a/backend/service/tests/view_schema_test.cpp +++ b/backend/service/tests/view_schema_test.cpp @@ -10,6 +10,7 @@ struct Config { int count{3}; bool enabled{true}; std::string color{"#336699"}; + std::string read_only{"fixed"}; }; } namespace adminive { @@ -17,18 +18,24 @@ template <> struct Type_Descriptor { static auto get() { using T = view_test::Config; - return object("view_config", "View Config", ADMINIVE_FIELD(T, name).editable().creatable().label("Name").text_input(), ADMINIVE_FIELD(T, count).editable().creatable().label("Count").number_input(), ADMINIVE_FIELD(T, enabled).editable().creatable().label("Enabled").boolean_input(), ADMINIVE_FIELD(T, color).editable().creatable().label("Color").color_input()); + return object("view_config", "View Config", ADMINIVE_FIELD(T, name).editable().creatable().label("Name").text_input(), ADMINIVE_FIELD(T, count).editable().creatable().label("Count").number_input(), ADMINIVE_FIELD(T, enabled).editable().creatable().label("Enabled").boolean_input(), ADMINIVE_FIELD(T, color).editable().creatable().label("Color").color_input(), ADMINIVE_FIELD(T, read_only).label("Read Only").text_input()); } }; template <> struct Type_View_Descriptor { static Form_View edit() { using T = view_test::Config; - return edit_form(vertical(group("Identity", horizontal(use<&T::name>(), use<&T::enabled>())), grid(2, use<&T::count>(), card("Appearance", use<&T::color>())), flow(card("Name Card", use<&T::name>()), card("Count Card", use<&T::count>())), tabs(tab("General", use<&T::name>(), use<&T::count>()), tab("Visual", use<&T::color>())))).submit("Save"); + return edit_form(vertical(group("Identity", horizontal(use<&T::name>(), use<&T::enabled>(), use<&T::read_only>())), grid(2, use<&T::count>(), card("Appearance", use<&T::color>())), flow(card("Name Card", use<&T::name>()), card("Count Card", use<&T::count>())), tabs(tab("General", use<&T::name>(), use<&T::count>()), tab("Visual", use<&T::color>())))).submit("Save"); } static Table_View table() { using T = view_test::Config; - return table_view(column<&T::name>("Name").search().fix(Table_Fixed::left), column<&T::count>("Count").sort(), column<&T::enabled>("Enabled").filter(), column<&T::color>("Color")).titled("Configs").default_sort("count", true).create_layout(grid(2, use<&T::name>(), use<&T::count>(), use<&T::enabled>(), use<&T::color>())).edit_layout(vertical(use<&T::name>(), horizontal(use<&T::count>(), use<&T::enabled>()), use<&T::color>())); + return table_view(column<&T::name>("Name").search().fix(Table_Fixed::left), column<&T::count>("Count").sort(), column<&T::enabled>("Enabled").filter(), column<&T::color>("Color")).titled("Configs").default_sort("count", true).create_layout(grid(2, use<&T::name>(), use<&T::count>(), use<&T::enabled>(), use<&T::color>())).edit_layout(vertical(use<&T::name>(), horizontal(use<&T::count>(), use<&T::enabled>()), use<&T::color>(), use<&T::read_only>())); + } + static Collection_View collection() { + Collection_View result; + result.table = table(); + result.table.name = "view_config_collection"; + return result; } }; } @@ -59,6 +66,12 @@ int main() { assert(form_amis.at("body").at(2).at("items").at(1).at("type") == "container"); assert(form_amis.at("body").at(2).at("items").at(0).at("body").at(0).at("type") == "card"); assert(form_amis.at("body").at(3).at("type") == "tabs"); + assert(form_amis.at("data").at("read_only") == "fixed"); + assert(form_amis.at("api").at("data").at("name") == "${name}"); + assert(form_amis.at("api").at("data").at("count") == "${count}"); + assert(form_amis.at("api").at("data").at("enabled") == "${enabled}"); + assert(form_amis.at("api").at("data").at("color") == "${color}"); + assert(!form_amis.at("api").at("data").contains("read_only")); const auto table_view = adminive::describe_table_view(); adminive::validate_table_view(table_view); const auto table_json = adminive::to_view_json(table_view); @@ -76,6 +89,14 @@ int main() { const auto composition_amis = adminive::to_amis_composition_schema(composition, {{"form", form_amis}, {"table", Json{{"type", "tpl"}, {"tpl", "table"}}}}); assert(composition_amis.at("body").at(0).at("type") == "tpl"); assert(composition_amis.at("body").at(1).at("type") == "grid"); + const auto five_columns = adminive::composition_view("five_columns", adminive::compose::horizontal(adminive::compose::slot("a"), adminive::compose::slot("b"), adminive::compose::slot("c"), adminive::compose::slot("d"), adminive::compose::slot("e"))); + const auto five_columns_amis = adminive::to_amis_composition_schema(five_columns, {{"a", Json{{"type", "tpl"}}}, {"b", Json{{"type", "tpl"}}}, {"c", Json{{"type", "tpl"}}}, {"d", Json{{"type", "tpl"}}}, {"e", Json{{"type", "tpl"}}}}); + const auto& five_widths = five_columns_amis.at("body").at(0).at("columns"); + assert(five_widths.at(0).at("md") == 3); + assert(five_widths.at(1).at("md") == 3); + assert(five_widths.at(2).at("md") == 2); + assert(five_widths.at(3).at("md") == 2); + assert(five_widths.at(4).at("md") == 2); const auto flow_composition = adminive::composition_view("flow_page", adminive::compose::flow(adminive::compose::slot("left"), adminive::compose::slot("right"))); const auto flow_amis = adminive::to_amis_composition_schema(flow_composition, {{"left", Json{{"type", "tpl"}, {"tpl", "left"}}}, {"right", Json{{"type", "tpl"}, {"tpl", "right"}}}}); assert(flow_amis.at("body").at(0).at("type") == "flex"); @@ -92,6 +113,12 @@ int main() { assert(frontend_composition_json.at("body").at("kind") == "frontend"); const auto frontend_composition_amis = adminive::to_amis_composition_schema(frontend_composition, {{"left", Json{{"type", "tpl"}, {"tpl", "left"}}}, {"right", Json{{"type", "tpl"}, {"tpl", "right"}}}}); assert(frontend_composition_amis.at("body").at(0).at("className") == "adminive-frontend-placement"); + const std::vector slot_contracts{{"left", "amis_schema", "/left/descriptor", "/left/view", "/left/data", "/left/amis"}, {"right", "amis_schema", "/right/descriptor", "/right/view", "/right/data", "/right/amis"}}; + const auto manifest = adminive::to_composition_manifest_json(frontend_composition, slot_contracts, {{"left", Json{{"type", "tpl"}, {"tpl", "left"}}}, {"right", Json{{"type", "tpl"}, {"tpl", "right"}}}}); + assert(manifest.at("protocol") == "adminive.composition-manifest"); + assert(manifest.at("view").at("body").at("kind") == "frontend"); + assert(manifest.at("slots").at("left").at("view_api") == "/left/view"); + assert(manifest.at("slots").at("right").at("schema").at("tpl") == "right"); const auto frontend_form = adminive::edit_form(adminive::frontend(adminive::list(adminive::use<&Config::name>(), adminive::use<&Config::count>()))); const auto frontend_form_json = adminive::to_view_json(frontend_form); assert(frontend_form_json.at("body").at("kind") == "frontend"); @@ -101,6 +128,18 @@ int main() { assert(table_amis.at("body").at(0).at("columns").at(1).at("searchable") == true); assert(table_amis.at("body").at(0).at("columns").at(2).at("sortable") == true); assert(table_amis.at("body").at(0).at("columns").at(3).at("filterable") == true); + const auto& create_submit_data = table_amis.at("body").at(0).at("headerToolbar").at(2).at("dialog").at("body").at("api").at("data"); + assert(create_submit_data.at("name") == "${name}"); + assert(create_submit_data.at("count") == "${count}"); + assert(create_submit_data.at("enabled") == "${enabled}"); + assert(create_submit_data.at("color") == "${color}"); + assert(!create_submit_data.contains("read_only")); + const auto& edit_submit_data = table_amis.at("body").at(0).at("columns").back().at("buttons").at(0).at("dialog").at("body").at("api").at("data"); + assert(edit_submit_data.at("name") == "${name}"); + assert(edit_submit_data.at("count") == "${count}"); + assert(edit_submit_data.at("enabled") == "${enabled}"); + assert(edit_submit_data.at("color") == "${color}"); + assert(!edit_submit_data.contains("read_only")); const auto list_amis = adminive::to_amis_list_schema("/configs", table_view, Json{{"title", "${name}"}}); assert(list_amis.at("body").at(0).at("mode") == "list"); assert(list_amis.at("body").at(0).at("id") == "view_config_table_crud_list"); @@ -116,6 +155,36 @@ int main() { assert(cards_amis.at("body").at(0).at("columnsCount") == 2); assert(cards_amis.at("body").at(0).at("card").at("actions").size() == 2); assert(cards_amis.at("body").at(0).at("card").at("actions").at(1).at("reload") == "view_config_table_crud_cards"); + const auto item_view = adminive::collection_item<&Config::name>().subtitle<&Config::color>().accent<&Config::color>().body<&Config::count, &Config::enabled>(); + const auto collection_view = adminive::describe_collection_view(); + const auto labeled_collection = collection_view.id_title("Record").create_title("Add").edit_title("Change").delete_title("Remove").actions_title("Operations").confirm_title("Apply").delete_confirmation("Remove record?").status_title("Status"); + assert(labeled_collection.table.id_label == "Record"); + assert(labeled_collection.table.create_label == "Add"); + assert(labeled_collection.table.edit_label == "Change"); + assert(labeled_collection.table.delete_label == "Remove"); + assert(labeled_collection.table.actions_label == "Operations"); + assert(labeled_collection.table.confirm_label == "Apply"); + assert(labeled_collection.table.delete_confirm == "Remove record?"); + assert(labeled_collection.table.view_status_label == "Status"); + const auto semantic_list_view = collection_view.as_list(item_view); + const auto semantic_list_json = adminive::to_view_json(semantic_list_view); + assert(semantic_list_json.at("kind") == "collection"); + assert(semantic_list_json.at("mode") == "list"); + assert(semantic_list_json.at("item").at("title_field") == "name"); + assert(semantic_list_json.at("item").at("fields").at(0) == "count"); + const auto semantic_list_amis = adminive::to_amis_collection_schema("/configs", semantic_list_view); + assert(semantic_list_amis.at("body").at(0).at("id") == "view_config_collection_crud_list"); + assert(semantic_list_amis.at("body").at(0).at("listItem").at("title") == "${name}"); + assert(semantic_list_amis.at("body").at(0).at("listItem").at("actions").at(1).at("reload") == "view_config_collection_crud_list"); + const auto semantic_cards_view = collection_view.as_cards(item_view, 4); + const auto semantic_cards_json = adminive::to_view_json(semantic_cards_view); + assert(semantic_cards_json.at("mode") == "cards"); + assert(semantic_cards_json.at("cards_columns") == 4); + const auto semantic_cards_amis = adminive::to_amis_collection_schema("/configs", semantic_cards_view); + assert(semantic_cards_amis.at("body").at(0).at("id") == "view_config_collection_crud_cards"); + assert(semantic_cards_amis.at("body").at(0).at("columnsCount") == 4); + const auto semantic_table_json = adminive::to_view_json(collection_view.as_table()); + assert(semantic_table_json.at("mode") == "table"); adminive::Collection_Service collection("/configs", {Config{"Alpha", 3, true, "#111111"}, Config{"Beta", 7, false, "#222222"}, Config{"Alphabet", 5, true, "#333333"}}); adminive::Collection_Query query; query.fields["name"] = "alpha"; @@ -138,6 +207,13 @@ int main() { query.fields["color"] = "#111111"; response = collection.list_response(query); assert(response.status == 400); + bool invalid_manifest{}; + try { + adminive::to_composition_manifest_json(frontend_composition, {adminive::Composition_Slot_Contract{"left", "amis_schema", {}, {}, {}, {}}}, {{"left", Json{{"type", "tpl"}}}}); + } catch(const std::invalid_argument&) { + invalid_manifest = true; + } + assert(invalid_manifest); bool invalid_grid{}; try { adminive::validate_form_view(adminive::edit_form(adminive::grid(0, adminive::use<&Config::name>()))); diff --git a/frontend/src/App.css b/frontend/src/App.css index ea6d308..bd7aa00 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -233,6 +233,9 @@ border: 1px solid #fecaca; border-radius: 10px; } +.frontend-layout { + min-width: 0; +} .frontend-horizontal { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -240,134 +243,96 @@ } .frontend-vertical { display: grid; - gap: 10px; + gap: 12px; } -.slot-demo { - height: 100%; - margin: 0; - padding: 13px; - border: 1px solid #dbeafe; - border-radius: 12px; - background: #fff; -} -.native-list { +.frontend-list { + display: grid; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; } -.native-list article { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 13px 14px; +.frontend-list .frontend-slot { border-bottom: 1px solid #e2e8f0; + border-radius: 0; } -.native-list article:last-child { +.frontend-list .frontend-slot:last-child { border-bottom: 0; } -.native-list small, -.native-card-head small { - display: block; - margin-top: 3px; - color: #64748b; -} -.color-dot { - width: 12px; - height: 12px; - border-radius: 999px; -} -.state-pill { - padding: 4px 8px; - border-radius: 999px; - color: #64748b; - background: #f1f5f9; - font-size: 12px; -} -.state-pill.enabled { - color: #047857; - background: #ecfdf5; -} -.native-cards { +.frontend-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } -.native-cards article { - position: relative; - overflow: hidden; - padding: 16px; - border: 1px solid #e2e8f0; - border-radius: 13px; +.frontend-slot { + min-width: 0; + padding: 13px; + border: 1px solid #dbe4f0; + border-radius: 12px; background: #fff; } -.card-accent { - position: absolute; - inset: 0 0 auto; - height: 4px; +.frontend-vertical .frontend-slot { + width: 100%; } -.native-card-head { +.slot-meta { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-top: 4px; + align-items: center; + flex-wrap: wrap; + gap: 7px; + margin-bottom: 10px; } -.native-card-head > span { - font-size: 28px; - font-weight: 800; +.slot-meta strong { + color: #0f172a; } -.meter { - height: 6px; - margin-top: 18px; - border-radius: 999px; - background: #e2e8f0; - overflow: hidden; +.slot-meta span { + padding: 3px 6px; + border-radius: 6px; + color: #1d4ed8; + background: #eff6ff; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.meter i { - display: block; - height: 100%; -} -.native-cards p { - margin: 10px 0 0; +.slot-meta code { + max-width: 100%; color: #64748b; - font-size: 13px; + font-size: 11px; + overflow-wrap: anywhere; } -.native-table-wrap { +.slot-demo { + min-width: 0; + margin: 0; +} +.slot-table-wrap { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 12px; } -.native-table { +.slot-table { width: 100%; border-collapse: collapse; font-size: 13px; } -.native-table th, -.native-table td { - padding: 11px 12px; +.slot-table th, +.slot-table td { + padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: left; - white-space: nowrap; + vertical-align: top; } -.native-table th { +.slot-table th { color: #475569; background: #f8fafc; } -.native-table tbody tr:last-child td { +.slot-table th:first-child, +.slot-table td:first-child { + width: 64px; +} +.slot-table th:nth-child(2), +.slot-table td:nth-child(2) { + width: 320px; +} +.slot-table tbody tr:last-child td { border-bottom: 0; } -.color-chip { - display: inline-flex; - align-items: center; - gap: 6px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} -.color-chip i { - width: 11px; - height: 11px; - border-radius: 3px; -} .mode-switch { display: inline-flex; gap: 5px; @@ -455,12 +420,96 @@ .protocol-panel pre { border-radius: 9px; } +.advanced-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +.doc-case-tabs { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-bottom: 14px; +} +.doc-case-tabs button { + padding: 8px 10px; + border: 1px solid #dbe4f0; + border-radius: 8px; + color: #475569; + background: #fff; + cursor: pointer; +} +.doc-case-tabs button.active { + border-color: #93c5fd; + color: #1d4ed8; + background: #eff6ff; +} +.doc-case { + margin-bottom: 18px; + padding: 16px; + border: 1px solid #dbe4f0; + border-radius: 13px; + background: #fbfdff; +} +.doc-case-head span { + color: #2563eb; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; +} +.doc-case-head h3 { + margin: 5px 0 6px; +} +.doc-case-head p { + margin: 0 0 14px; + color: #64748b; + line-height: 1.6; +} +.doc-preview { + margin-bottom: 14px; + padding: 14px; + border: 1px solid #dbe4f0; + border-radius: 12px; + background: #fff; +} +.doc-preview h4 { + margin: 0 0 10px; +} +.doc-code-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +.code-panel { + min-width: 0; + border: 1px solid #dbe4f0; + border-radius: 12px; + background: #0f172a; + overflow: hidden; +} +.code-panel h4 { + margin: 0; + padding: 12px 14px; + color: #dbeafe; + background: #172033; +} +.code-panel pre { + min-height: 160px; + max-height: 560px; + margin: 0; + padding: 15px; + overflow: auto; + color: #dbeafe; + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} @media (max-width: 1120px) { .layer-flow { grid-template-columns: repeat(3, minmax(0, 1fr)); } .frontend-horizontal, - .native-cards { + .frontend-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -487,9 +536,11 @@ .layer-flow, .capability-grid, .frontend-horizontal, - .native-cards, + .frontend-cards, .json-grid, - .doc-layout { + .advanced-grid, + .doc-layout, + .doc-code-grid { grid-template-columns: 1fr; } .protocol-tags { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 676924b..569264d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,15 +5,45 @@ import JsonPanel from './JsonPanel'; import type {Api_Envelope} from './types'; import './App.css'; type Amis_Schema = Record; -type Section_Id = 'overview' | 'backend' | 'frontend' | 'form' | 'collection' | 'runtime' | 'docs'; +type Section_Id = 'overview' | 'backend' | 'frontend' | 'form' | 'collection' | 'advanced' | 'runtime' | 'docs'; type Collection_Mode = 'list' | 'cards' | 'table'; -interface Gallery_Item { - id: number; +type Form_Mode = 'display' | 'create' | 'edit'; +type Frontend_Layout_Mode = 'horizontal' | 'vertical' | 'list' | 'cards' | 'table'; +interface Composition_Node { + kind: string; + slot?: string; + children?: Composition_Node[]; +} +interface Composition_View { + protocol: string; + protocol_version: number; + kind: string; name: string; - category: string; - score: number; - enabled: boolean; - color: string; + title?: string; + body: Composition_Node; +} +interface Slot_Contract { + component_kind: string; + descriptor_api?: string; + view_api?: string; + data_api?: string; + amis_api?: string; + schema: Amis_Schema; +} +interface Frontend_Manifest { + protocol: string; + protocol_version: number; + view: Composition_View; + slots: Record; +} +interface Doc_Case { + id: string; + title: string; + summary: string; + cpp: string; + view: unknown; + amis: unknown; + preview: Amis_Schema; } interface Documentation { title: string; @@ -22,20 +52,24 @@ interface Documentation { capabilities: Record; endpoints: string[]; examples: Record; -} -interface Frontend_Manifest { - view: unknown; - slots: Record; - items: Gallery_Item[]; + cases: Doc_Case[]; } const sections: Array<{id: Section_Id; title: string; subtitle: string}> = [ {id: 'overview', title: '总览', subtitle: '能力地图'}, {id: 'backend', title: '后端组合', subtitle: '后端决定定位'}, - {id: 'frontend', title: '前端定位', subtitle: '前端决定位置'}, + {id: 'frontend', title: '前端定位', subtitle: '同一批 Slot 五种布局'}, {id: 'form', title: '字段与表单', subtitle: '控件 + View'}, - {id: 'collection', title: '集合画廊', subtitle: '列表 / 卡片 / 表格'}, + {id: 'collection', title: '集合画廊', subtitle: '一等 Collection View'}, + {id: 'advanced', title: '高级能力', subtitle: 'Adapter + 多态 + 校验'}, {id: 'runtime', title: '完整运行时', subtitle: '状态 + 配置 + 多类型'}, - {id: 'docs', title: '协议文档', subtitle: 'JSON + API'} + {id: 'docs', title: '活文档', subtitle: 'C++ + View JSON + AMIS'} +]; +const frontend_layouts: Array<{id: Frontend_Layout_Mode; title: string}> = [ + {id: 'horizontal', title: '横着'}, + {id: 'vertical', title: '竖着'}, + {id: 'list', title: '列表'}, + {id: 'cards', title: '卡片'}, + {id: 'table', title: '表格'} ]; function as_schema(value: unknown): Amis_Schema { return value as Amis_Schema; @@ -47,30 +81,47 @@ async function fetch_data(url: string): Promise { } return response.data.data; } +function collect_slot_names(node: Composition_Node, result: string[] = []): string[] { + if(node.kind === 'slot' && node.slot) { + result.push(node.slot); + } + for(const child of node.children ?? []) { + collect_slot_names(child, result); + } + return result; +} function CapabilityGrid({capabilities}: {capabilities: Record}) { return
{Object.entries(capabilities).map(([name, items]) =>

{name}

{items.map(item => {item})}
)}
; } -function FrontendSlots({slots, className}: {slots: Record; className: string}) { - return
{['alpha', 'beta', 'gamma'].map(name => )}
; +function SlotMeta({name, slot}: {name: string; slot: Slot_Contract}) { + return
{name}{slot.component_kind}{slot.view_api && {slot.view_api}}
; } -function NativeList({items}: {items: Gallery_Item[]}) { - return
{items.map(item =>
{item.name}{item.category} · 评分 {item.score}
{item.enabled ? '启用' : '停用'}
)}
; +function SlotBody({name, slot}: {name: string; slot: Slot_Contract}) { + return <>; } -function NativeCards({items}: {items: Gallery_Item[]}) { - return
{items.map(item =>
{item.name}{item.category}
{item.score}

{item.enabled ? '当前已启用' : '当前已停用'}

)}
; +function FrontendLayout({manifest, mode}: {manifest: Frontend_Manifest; mode: Frontend_Layout_Mode}) { + const names = collect_slot_names(manifest.view.body); + const entries = names.map(name => ({name, slot: manifest.slots[name]})).filter((entry): entry is {name: string; slot: Slot_Contract} => Boolean(entry.slot)); + if(mode === 'table') { + return
{entries.map((entry, index) => )}
顺序Slot 契约组件
{index + 1}
; + } + return
{entries.map((entry, index) =>
)}
; } -function NativeTable({items}: {items: Gallery_Item[]}) { - return
{items.map(item => )}
ID名称分类评分状态颜色
{item.id}{item.name}{item.category}{item.score}{item.enabled ? '启用' : '停用'}{item.color}
; +function LiveDocs({docs}: {docs: Documentation}) { + const [case_id, set_case_id] = useState(docs.cases[0]?.id ?? ''); + const [json_example, set_json_example] = useState('descriptor'); + const active_case = docs.cases.find(item => item.id === case_id) ?? docs.cases[0]; + return <>
{docs.cases.map(item => )}
{active_case &&
LIVE DOCUMENTATION

{active_case.title}

{active_case.summary}

实际效果

C++ 声明

{active_case.cpp}
}

HTTP 接口

{docs.endpoints.map(endpoint => {endpoint})}

全部真实 JSON

{JSON.stringify(docs.examples[json_example], null, 2)}
; } export default function App() { const [section, set_section] = useState('overview'); const [collection_mode, set_collection_mode] = useState('list'); + const [form_mode, set_form_mode] = useState('edit'); const [docs, set_docs] = useState(); const [manifest, set_manifest] = useState(); const [full_schema, set_full_schema] = useState(); const [loading, set_loading] = useState(true); const [error, set_error] = useState(''); - const [json_example, set_json_example] = useState('descriptor'); const load_all = useCallback(async () => { set_loading(true); set_error(''); @@ -92,12 +143,10 @@ export default function App() { useEffect(() => { void load_all(); }, [load_all]); - const collection_schema = useMemo(() => { - if(!docs) { - return undefined; - } - return as_schema(docs.examples[`${collection_mode}_amis`]); - }, [collection_mode, docs]); + const collection_schema = useMemo(() => docs ? as_schema(docs.examples[`${collection_mode}_amis`]) : undefined, [collection_mode, docs]); + const collection_view = useMemo(() => docs?.examples[`collection_${collection_mode}_view`], [collection_mode, docs]); + const form_schema = useMemo(() => docs ? as_schema(docs.examples[`form_${form_mode}_amis`]) : undefined, [docs, form_mode]); + const form_view = useMemo(() => docs?.examples[`form_${form_mode}_view`], [docs, form_mode]); const active_section = sections.find(item => item.id === section) ?? sections[0]; - return
ADMINIVE COMPONENT GALLERY

后端驱动,但不把前端绑死

同一套 Descriptor / View / Composition 同时展示后端决定布局与前端决定定位;字段、列表、卡片、表格、状态、配置和实际 JSON 协议都在这个画廊里。

{active_section.subtitle}

{active_section.title}

adminive.view v1AMIS AdapterReact Layout
{loading &&
正在加载画廊描述……
}{!loading && error &&
{error}
}{!loading && !error && docs && manifest && <>{section === 'overview' && <>

{docs.title}

{docs.summary}

{docs.layers.map((layer, index) =>
{index + 1}{layer.name}{layer.purpose}
)}
}{section === 'backend' && <>
后端组合类型决定定位

这里的横向、竖向、列表、Grid、Card、Flow、Tabs 都来自后端 Composition JSON;前端只把最终 AMIS schema 渲染出来。

}{section === 'frontend' && <>
frontend 节点:后端决定“有哪些组件”,前端决定“放在哪里”

下面三块 Alpha / Beta / Gamma 都来自同一个后端 slot 集合。横排和竖排只改 React CSS,没有复制字段 Schema。

横着

竖着

列表

卡片

表格

}{section === 'form' && <>
字段事实、字段呈现、Form View 分层

automatic、text、textarea、number、range、validated、boolean、select、date、color、optional、visible_on、readonly、nested object 全部在同一个真实编辑表单里。

}{section === 'collection' && <>
{collection_schema && }
}{section === 'runtime' && full_schema && <>
完整库能力联动

这里保留原来的完整后端页面:组件画廊、状态轮询、对象配置、事务持久化、多类型设备和 Collection CRUD 一起运行。

}{section === 'docs' && <>

HTTP 接口

{docs.endpoints.map(endpoint => {endpoint})}

真实 JSON 样式

{JSON.stringify(docs.examples[json_example], null, 2)}
}}
; + return
ADMINIVE COMPONENT GALLERY

后端驱动,但不把前端绑死

Descriptor、Form View、Collection View、Composition Manifest 和 AMIS 全部直接展示;后端决定定位与 frontend 授权前端定位两套模式可以在同一页面并存。

{active_section.subtitle}

{active_section.title}

adminive.view v1composition-manifest v1AMIS Adapter
{loading &&
正在加载画廊描述……
}{!loading && error &&
{error}
}{!loading && !error && docs && manifest && <>{section === 'overview' && <>

{docs.title}

{docs.summary}

{docs.layers.map((layer, index) =>
{index + 1}{layer.name}{layer.purpose}
)}
}{section === 'backend' && <>
后端组合类型决定定位

horizontal、vertical、list、grid、card、flow、tabs 都来自后端 Composition View;AMIS Adapter 只做 renderer 翻译。Grid 的 12 栅格宽度现在按余数均匀分配,不再留下无意义空格。

}{section === 'frontend' && <>
同一个 frontend 节点,同一批 slot,五种布局

React 不再写死 alpha / beta / gamma,也不再知道 Gallery_Row 的业务字段。它递归读取 Composition View 中的 slot 顺序,再从正式 slot registry 取得 schema 和来源接口;下面五个 Demo 只改变布局 renderer。

{frontend_layouts.map(layout =>

{layout.title}

)}
}{section === 'form' && <>
字段事实、字段呈现、Form View 分层

automatic、text、textarea、number、range、validated、boolean、select、date、color、optional、visible_on、readonly、nested object 都来自同一 Descriptor;display / create / edit 三种 mode 直接决定权限与 renderer,提交 payload 只映射当前 View 中可 editable / creatable 的字段。

{form_schema && }
}{section === 'collection' && <>
list / cards / table 已经是一等 Collection View mode

三种展示不再从 table AMIS JSON 生成后再二次改写。View JSON 本身直接带 kind=collection 和 mode,CRUD id、reload、toolbar 和 item renderer 在生成阶段一次确定。

{collection_schema && }
}{section === 'advanced' && <>
不是能力列表,而是实际可操作 Demo

Value_Adapter、Control_Adapter、sensitive、Polymorphic_Adapter、Object_Adapter、Resource_Transaction、422 字段错误和 Status Polling 都使用真实 Descriptor / View / AMIS / HTTP 路由。

Value / Control Adapter + Sensitive

Polymorphic Adapter

Object Adapter + Resource Transaction

Status Polling

}{section === 'runtime' && full_schema && <>
完整库能力联动

保留完整运行时:状态轮询、对象配置、事务持久化、多类型设备、Collection CRUD、校验和同步都走真实后端接口。高级 Adapter / polymorphic 能力同时列入活文档能力矩阵。

}{section === 'docs' && }}
; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b41be7d..fd3b7a0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,8 @@ import {defineConfig, type Plugin} from 'vite'; const output_dir = fileURLToPath(new URL('../frontend_dist', import.meta.url)); const cache_dir = fileURLToPath(new URL('./node_modules/.vite-adminive', import.meta.url)); const amis_sdk_dir = fileURLToPath(new URL('./node_modules/amis/sdk', import.meta.url)); -const amis_sdk_url_prefix = '/vendor/amis/'; +const amis_package = JSON.parse(readFileSync(fileURLToPath(new URL('./node_modules/amis/package.json', import.meta.url)), 'utf8')) as {version: string}; +const amis_sdk_url_prefix = `/vendor/amis/${amis_package.version}/`; function content_type(file_name: string) { switch(extname(file_name).toLowerCase()) { case '.css': @@ -48,7 +49,7 @@ function amis_sdk_plugin(): Plugin { } this.emitFile({ type: 'asset', - fileName: `vendor/amis/${relative_path}`, + fileName: `vendor/amis/${amis_package.version}/${relative_path}`, source: readFileSync(source_path) }); }