diff --git a/README.md b/README.md index 09f2fe1..9001538 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ View Schema Form/Table 语义组合 Composition View 多组件页面组合 ``` -后端保留完整的组合能力,包括 `vertical`、`horizontal`、`flow`、`grid`、`group`、`card`、`tabs` 和表格。区别只在于这些能力现在位于独立层:字段定义不再携带表格列顺序/排序等信息,AMIS Adapter 也不再承担业务组合决策。 +后端保留完整的组合能力,包括 `frontend`、`vertical`、`horizontal`、`flow`、`grid`、`list`、`group`、`card`、`tabs` 和表格。`frontend` 是显式的布局授权节点:后端只声明成员与顺序,最终横排、竖排、网格或响应式位置由前端决定;其余组合节点仍由后端决定语义位置。字段定义不携带表格列顺序/排序等信息,AMIS Adapter 也不承担业务组合决策。 例如一个配置对象可以只定义字段事实和单字段控件: @@ -94,27 +94,40 @@ const auto page = composition_view("device_page", compose::vertical( )); ``` -`slot` 引用已经生成的 Form/Table/Status 等组件,后端仍然决定语义组合,AMIS Adapter 只负责把组合翻译成具体 AMIS JSON。前端可以进一步决定 CSS、断点、列宽等纯视觉细节。 +`slot` 引用已经生成的 Form/Table/Status 等组件。普通 Composition 节点由后端决定语义组合,AMIS Adapter 只负责翻译;`compose::frontend(...)` 则明确把最终定位权交给前端,同时仍由后端决定页面有哪些组件。这样两种模式可以在同一个项目里并存。 ### 示例组件画廊 完整示例首页现在包含“组件画廊”页签。画廊不是前端写死的组件清单,而是后端使用 Adminive 自身 Descriptor、View Schema、Composition View 和 Collection Service 生成,用于直接检查当前支持能力的实际 AMIS 效果。 -字段画廊覆盖 `automatic`、`text`、`multiline_text`、`number`、`boolean`、`select`、`date`、`color`、`std::optional`、只读 static、条件联动和嵌套对象;同一表单同时覆盖 `vertical`、`horizontal`、`grid`、`flow`、`group`、`card` 和 `tabs`。表格画廊提供真实的新增、编辑、删除、刷新、列控制、搜索、筛选、排序和拖拽顺序接口。第三个页签直接展示 Composition 层的 `heading`、`horizontal`、`grid`、`flow`、`group`、`card`、`tabs` 和 `slot`。 +字段画廊覆盖 `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`。 + +React 首页现在本身就是文档与组件画廊:可以切换“后端组合定位”和“前端决定定位”,前端定位页用同一批后端 slot 分别演示横排、竖排,并把同一份 Collection 数据渲染成列表、卡片和表格;协议文档页直接显示真实 Descriptor、Form/Table View、Composition View、AMIS 和数据 JSON。 画廊资源同时提供正常 Adminive HTTP 接口: ```text +/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 ``` -`flow` 在 AMIS Adapter 中翻译成可换行 Flex,每个 `items` 元素都是显式 `container` renderer。这样 Flow 的子 Card/Form Control 不会以无 `type` 的包装对象进入 AMIS renderer tree。 +`flow` 在 AMIS Adapter 中翻译成可换行 Flex,每个 `items` 元素都是显式 `container` renderer。`list` 保留条目语义并生成明确的条目容器;`frontend` 在 AMIS 中使用中性竖向 fallback,但原始 View JSON 保留 `kind: "frontend"`,自定义前端可以接管最终位置。静态首页禁止缓存,带内容 hash 的 `/assets/` 和版本固定的 `/vendor/amis/` 使用长期 immutable 缓存,避免浏览器继续持有旧 `index.html` 后引用已经不存在的动态 chunk。 ## 后端分层 diff --git a/backend/library/DESIGN.md b/backend/library/DESIGN.md index c75b6f3..aea9f8b 100644 --- a/backend/library/DESIGN.md +++ b/backend/library/DESIGN.md @@ -227,10 +227,12 @@ Form View 可以组合: ```text all_fields field +frontend vertical horizontal flow grid +list group card tabs / tab @@ -261,16 +263,18 @@ create/edit form layout ```text slot heading +frontend vertical horizontal flow grid +list group card tabs / tab ``` -`slot` 只是引用一个已经生成的业务组件。例如“上方配置表单 + 下方设备表格”可以由后端描述为一个 vertical composition,而不需要业务代码直接拼 AMIS `grid/card/tpl` JSON。 +`slot` 只是引用一个已经生成的业务组件。例如“上方配置表单 + 下方设备表格”可以由后端描述为一个 vertical composition,而不需要业务代码直接拼 AMIS `grid/card/tpl` JSON。`list` 表示显式的纵向条目集合;`frontend` 则只声明子节点及其顺序,不声明最终布局,让前端根据设备、容器或产品形态决定横排、竖排、列表、卡片或表格等视觉组织。 ### 5.5 后端与前端的最终边界 @@ -292,13 +296,16 @@ form/table/page 的语义组合 具体 renderer CSS / theme 响应式断点 +frontend 节点的最终组合方式 最终列宽和像素级间距 设备尺寸下的视觉降级 ``` 因此核心原则是: -> **后端拥有页面的语义结构,前端拥有页面的视觉实现。** +> **后端默认拥有页面的语义结构;当后端显式使用 frontend 节点时,只交付内容与顺序,由前端拥有该节点的最终定位;所有节点的视觉实现始终属于前端。** + +这两个模式不能混淆:`vertical/horizontal/flow/grid/list/group/card/tabs` 表示后端决定组合语义,`frontend` 表示后端明确放弃该层的布局决定权。前端不能擅自把普通后端组合节点解释成另一种业务结构。 这样既满足“删字段、加字段、改配置只改后端”,又避免后端被某个具体前端框架的 CSS/Grid 实现锁死。 @@ -731,6 +738,8 @@ CRUD schema ```text 获取后端描述 +按普通组合节点执行后端布局语义 +对 frontend 节点自行决定最终定位 渲染页面 提交用户输入 展示后端错误 @@ -908,10 +917,11 @@ schema validation ```text Field Presentation 与结构事实分离 -Form View 的 group/card/grid/flow/tabs 组合 +Form View 的 frontend/group/card/grid/flow/list/tabs 组合 Table View 的列选择与顺序 +Collection 的 table/list/cards 三种 Adapter 结果与独立 CRUD id/reload 目标 sortable/searchable/filterable/fixed 的后端能力约束 -Composition View 的 slot 与跨组件组合 +Composition View 的 slot、frontend 与跨组件组合 /view JSON 与 AMIS Adapter 的语义一致性 ``` diff --git a/backend/library/include/adminive/amis.hpp b/backend/library/include/adminive/amis.hpp index ecc559b..6a61a8d 100644 --- a/backend/library/include/adminive/amis.hpp +++ b/backend/library/include/adminive/amis.hpp @@ -428,6 +428,32 @@ Json make_amis_view_node(const View_Node& node, const Json& descriptor_json, std if(node.kind == View_Node_Kind::field) { return make_typed_amis_control_by_name(descriptor_json, descriptor, node.field, permission, prefix); } + if(node.kind == View_Node_Kind::frontend) { + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "body", make_amis_view_body(View_Node{View_Node_Kind::vertical, {}, {}, {}, {}, 0, false, false, node.children}, descriptor_json, permission, prefix)); + json_set(result, "className", "adminive-frontend-placement"); + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::list) { + Json body = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "type", "container"); + json_set(item, "body", make_amis_view_body(child, descriptor_json, permission, prefix)); + Json style = json_object(); + json_set(style, "padding", "10px 0"); + json_set(style, "borderBottom", "1px solid #e5e7eb"); + json_set(item, "style", std::move(style)); + json_append(body, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "body", std::move(body)); + apply_view_node_options(result, node, prefix); + return result; + } if(node.kind == View_Node_Kind::group) { Json result = json_object(); json_set(result, "type", "fieldset"); @@ -571,6 +597,32 @@ Json make_amis_composition_node(const Composition_Node& node, const std::map(); + json_set(result, "type", "container"); + json_set(result, "body", make_amis_composition_body(Composition_Node{Composition_Node_Kind::vertical, {}, {}, {}, {}, {}, 0, false, false, node.children}, slots)); + json_set(result, "className", "adminive-frontend-placement"); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::list) { + Json body = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "type", "container"); + json_set(item, "body", make_amis_composition_body(child, slots)); + Json style = json_object(); + json_set(style, "padding", "10px 0"); + json_set(style, "borderBottom", "1px solid #e5e7eb"); + json_set(item, "style", std::move(style)); + json_append(body, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "body", std::move(body)); + apply_composition_node_options(result, node); + return result; + } if(node.kind == Composition_Node_Kind::group) { Json result = json_object(); json_set(result, "type", "panel"); @@ -943,5 +995,64 @@ 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_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)); +} +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); +} } diff --git a/backend/library/include/adminive/composition.hpp b/backend/library/include/adminive/composition.hpp index 6d5c93a..3267241 100644 --- a/backend/library/include/adminive/composition.hpp +++ b/backend/library/include/adminive/composition.hpp @@ -8,10 +8,12 @@ namespace adminive { enum class Composition_Node_Kind { slot, heading, + frontend, vertical, horizontal, flow, grid, + list, group, card, tabs, @@ -77,6 +79,10 @@ Composition_Node make_container(Composition_Node_Kind kind, Children&&... childr return result; } template +Composition_Node frontend(Children&&... children) { + return make_container(Composition_Node_Kind::frontend, std::forward(children)...); +} +template Composition_Node vertical(Children&&... children) { return make_container(Composition_Node_Kind::vertical, std::forward(children)...); } @@ -95,6 +101,10 @@ Composition_Node grid(std::size_t columns, Children&&... children) { return result; } template +Composition_Node list(Children&&... children) { + return make_container(Composition_Node_Kind::list, std::forward(children)...); +} +template Composition_Node group(std::string title, Children&&... children) { auto result = make_container(Composition_Node_Kind::group, std::forward(children)...); result.title = std::move(title); @@ -130,13 +140,16 @@ struct Composition_View { inline Composition_View composition_view(std::string name, Composition_Node body) { return Composition_View{std::move(name), {}, std::move(body)}; } -inline void validate_composition_node(const Composition_Node& node) { +inline void validate_composition_node_impl(const Composition_Node& node, bool tab_allowed) { if(node.kind == Composition_Node_Kind::slot && node.slot.empty()) { throw std::invalid_argument("composition slot name must not be empty"); } if(node.kind == Composition_Node_Kind::grid && node.columns == 0) { throw std::invalid_argument("composition grid requires at least one column"); } + if(node.kind == Composition_Node_Kind::tab && !tab_allowed) { + throw std::invalid_argument("composition tab can only be used inside tabs"); + } if(node.kind == Composition_Node_Kind::tabs) { for(const auto& child : node.children) { if(child.kind != Composition_Node_Kind::tab) { @@ -145,9 +158,12 @@ inline void validate_composition_node(const Composition_Node& node) { } } for(const auto& child : node.children) { - validate_composition_node(child); + validate_composition_node_impl(child, node.kind == Composition_Node_Kind::tabs); } } +inline void validate_composition_node(const Composition_Node& node) { + validate_composition_node_impl(node, false); +} inline void validate_composition_view(const Composition_View& view) { if(view.name.empty()) { throw std::invalid_argument("composition view name must not be empty"); diff --git a/backend/library/include/adminive/view.hpp b/backend/library/include/adminive/view.hpp index 60228a7..1e33bfb 100644 --- a/backend/library/include/adminive/view.hpp +++ b/backend/library/include/adminive/view.hpp @@ -12,10 +12,12 @@ namespace adminive { enum class View_Node_Kind { all_fields, field, + frontend, vertical, horizontal, flow, grid, + list, group, card, tabs, @@ -107,6 +109,10 @@ View_Node make_view_container(View_Node_Kind kind, Children&&... children) { return result; } template +View_Node frontend(Children&&... children) { + return make_view_container(View_Node_Kind::frontend, std::forward(children)...); +} +template View_Node vertical(Children&&... children) { return make_view_container(View_Node_Kind::vertical, std::forward(children)...); } @@ -125,6 +131,10 @@ View_Node grid(std::size_t columns, Children&&... children) { return result; } template +View_Node list(Children&&... children) { + return make_view_container(View_Node_Kind::list, std::forward(children)...); +} +template View_Node group(std::string title, Children&&... children) { auto result = make_view_container(View_Node_Kind::group, std::forward(children)...); result.title = std::move(title); @@ -380,7 +390,7 @@ bool descriptor_has_field(const Descriptor& descriptor, std::string_view name) { return found; } template -void validate_view_node(const View_Node& node) { +void validate_view_node_impl(const View_Node& node, bool tab_allowed) { const auto descriptor = describe(); if(node.kind == View_Node_Kind::field && !descriptor_has_field(descriptor, node.field)) { throw std::invalid_argument("view field does not exist: " + node.field); @@ -388,6 +398,9 @@ void validate_view_node(const View_Node& node) { if(node.kind == View_Node_Kind::grid && node.columns == 0) { throw std::invalid_argument("grid view requires at least one column"); } + if(node.kind == View_Node_Kind::tab && !tab_allowed) { + throw std::invalid_argument("tab view can only be used inside tabs"); + } if(node.kind == View_Node_Kind::tabs) { for(const auto& child : node.children) { if(child.kind != View_Node_Kind::tab) { @@ -396,10 +409,14 @@ void validate_view_node(const View_Node& node) { } } for(const auto& child : node.children) { - validate_view_node(child); + validate_view_node_impl(child, node.kind == View_Node_Kind::tabs); } } template +void validate_view_node(const View_Node& node) { + validate_view_node_impl(node, false); +} +template void validate_form_view(const Form_View& view) { if(view.name.empty()) { throw std::invalid_argument("form view name must not be empty"); diff --git a/backend/library/include/adminive/view_json.hpp b/backend/library/include/adminive/view_json.hpp index 752f3af..5aea5cc 100644 --- a/backend/library/include/adminive/view_json.hpp +++ b/backend/library/include/adminive/view_json.hpp @@ -9,6 +9,8 @@ inline std::string_view composition_node_kind_name(Composition_Node_Kind value) return "slot"; case Composition_Node_Kind::heading: return "heading"; + case Composition_Node_Kind::frontend: + return "frontend"; case Composition_Node_Kind::vertical: return "vertical"; case Composition_Node_Kind::horizontal: @@ -17,6 +19,8 @@ inline std::string_view composition_node_kind_name(Composition_Node_Kind value) return "flow"; case Composition_Node_Kind::grid: return "grid"; + case Composition_Node_Kind::list: + return "list"; case Composition_Node_Kind::group: return "group"; case Composition_Node_Kind::card: @@ -83,6 +87,8 @@ inline std::string_view view_node_kind_name(View_Node_Kind value) noexcept { return "all_fields"; case View_Node_Kind::field: return "field"; + case View_Node_Kind::frontend: + return "frontend"; case View_Node_Kind::vertical: return "vertical"; case View_Node_Kind::horizontal: @@ -91,6 +97,8 @@ inline std::string_view view_node_kind_name(View_Node_Kind value) noexcept { return "flow"; case View_Node_Kind::grid: return "grid"; + case View_Node_Kind::list: + return "list"; case View_Node_Kind::group: return "group"; case View_Node_Kind::card: diff --git a/backend/service/src/example_descriptors.hpp b/backend/service/src/example_descriptors.hpp index 090dcaf..d6e8c86 100644 --- a/backend/service/src/example_descriptors.hpp +++ b/backend/service/src/example_descriptors.hpp @@ -96,7 +96,7 @@ template <> struct Type_View_Descriptor { static Form_View edit() { using T = example::Gallery_Config; - return edit_form(vertical(group("文本输入", horizontal(use<&T::automatic_text>(), use<&T::text_value>()), use<&T::multiline_value>(), use<&T::optional_text>()), group("数字与选择", grid(4, use<&T::number_value>(), use<&T::even_number>(), use<&T::boolean_value>(), use<&T::select_value>())), flow(card("日期", use<&T::date_value>()), card("颜色", use<&T::color_value>())), tabs(tab("条件联动", use<&T::show_conditional>(), use<&T::conditional_text>()), tab("嵌套对象", use<&T::nested>()), tab("只读字段", use<&T::read_only_text>())))).titled("字段控件与表单布局").submit("保存画廊数据"); + return edit_form(vertical(group("文本输入", horizontal(use<&T::automatic_text>(), use<&T::text_value>()), list(use<&T::multiline_value>(), use<&T::optional_text>())), group("数字与选择", grid(4, use<&T::number_value>(), use<&T::even_number>(), use<&T::boolean_value>(), use<&T::select_value>())), flow(card("日期", use<&T::date_value>()), card("颜色", use<&T::color_value>())), tabs(tab("条件联动", use<&T::show_conditional>(), use<&T::conditional_text>()), tab("嵌套对象", use<&T::nested>()), tab("前端定位语义", frontend(use<&T::read_only_text>()))))).titled("字段控件与表单布局").submit("保存画廊数据"); } }; template <> diff --git a/backend/service/src/gallery.cpp b/backend/service/src/gallery.cpp index ccd0588..37c04b0 100644 --- a/backend/service/src/gallery.cpp +++ b/backend/service/src/gallery.cpp @@ -7,35 +7,160 @@ namespace { Json make_gallery_note(std::string title, std::string text) { return Json{{"type", "tpl"}, {"tpl", "" + std::move(title) + "
" + std::move(text) + "
"}}; } +Composition_View make_backend_layout_view() { + return composition_view("component_gallery", compose::tabs( + compose::tab("字段表单", compose::slot("form")), + compose::tab("后端组合定位", compose::vertical( + compose::heading("后端 Composition 决定组件位置").accent("#2563eb"), + compose::group("横向 horizontal", compose::horizontal(compose::card("横向 A", compose::slot("horizontal_a")), compose::card("横向 B", compose::slot("horizontal_b")), compose::card("横向 C", compose::slot("horizontal_c")))), + compose::group("竖向 vertical", compose::vertical(compose::card("第一项", compose::slot("vertical_a")), compose::card("第二项", compose::slot("vertical_b")), compose::card("第三项", compose::slot("vertical_c")))), + compose::group("列表 list", compose::list(compose::slot("list_a"), compose::slot("list_b"), compose::slot("list_c"))), + 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 节点只声明组件成员与顺序,具体横排、竖排、网格、列表由前端决定。")) + )).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)}}; +} +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)}}; +} +Json string_array(std::initializer_list values) { + Json result = Json::array(); + for(const char* value : values) { + result.push_back(value); + } + return result; +} } Component_Gallery::Component_Gallery() : config_resource_(config_, "/admin/gallery/config"), rows_("/admin/gallery/items", make_rows()) {} Json Component_Gallery::amis_schema() const { const Json form = config_resource_.amis_schema(); const Json table = rows_.amis_schema(); - const auto composition = composition_view("component_gallery", compose::tabs(compose::tab("字段控件与布局", compose::slot("form")), compose::tab("表格与操作", compose::slot("table")), compose::tab("组合能力", compose::vertical(compose::heading("Composition View 组合画廊").accent("#2563eb"), compose::group("横向与网格", compose::horizontal(compose::card("Horizontal A", compose::slot("horizontal_a")), compose::card("Horizontal B", compose::slot("horizontal_b"))), compose::grid(3, compose::card("Grid 1", compose::slot("grid_1")), compose::card("Grid 2", compose::slot("grid_2")), compose::card("Grid 3", compose::slot("grid_3")))), 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())))).titled("Adminive 组件画廊"); + const auto composition = make_backend_layout_view(); std::map slots; slots.emplace("form", form); - slots.emplace("table", table.at("body")); - slots.emplace("horizontal_a", make_gallery_note("horizontal", "两个组件按横向语义组合,具体栅格由 AMIS Adapter 翻译。")); - slots.emplace("horizontal_b", make_gallery_note("card", "Card 是组合层容器,不和字段 Presentation 混在一起。")); - slots.emplace("grid_1", make_gallery_note("grid", "后端定义逻辑列数。")); - slots.emplace("grid_2", make_gallery_note("slot", "Slot 可以嵌入 Form、Table、Status 或其他后端组件。")); - slots.emplace("grid_3", make_gallery_note("group", "Group 可以折叠并继续嵌套其他组合。")); - slots.emplace("flow_a", make_gallery_note("flow", "Flow 使用可换行 Flex,子项都是合法 container renderer。")); - slots.emplace("flow_b", make_gallery_note("tabs", "整个画廊本身就是 Composition tabs。")); - slots.emplace("flow_c", make_gallery_note("vertical", "纵向组合只表达顺序,不泄漏具体 CSS。")); - slots.emplace("flow_d", make_gallery_note("heading", "标题也是组合节点,可以独立使用。")); + 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("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_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 由后端声明逻辑列数。")); + slots.emplace("card_c", make_gallery_note("nest", "Card、Grid、Group、Tabs 可以互相嵌套。")); + slots.emplace("flow_a", make_gallery_note("flow", "Flow 使用可换行 Flex。")); + slots.emplace("flow_b", make_gallery_note("flow", "适合宽度不固定的组件。")); + slots.emplace("flow_c", make_gallery_note("flow", "每个子项都是合法 renderer。")); + 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("frontend_c", make_gallery_note("fallback", "AMIS Adapter 对 frontend 节点提供中性的竖向 fallback,不偷做业务布局决策。")); return to_amis_composition_schema(composition, slots); } +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()}}; +} +Json Component_Gallery::list_schema() const { + return to_amis_list_schema("/admin/gallery/items", describe_table_view(), make_list_item()); +} +Json Component_Gallery::cards_schema() const { + return to_amis_cards_schema("/admin/gallery/items", describe_table_view(), make_card(), 3); +} +Json Component_Gallery::documentation() const { + Json config_data; + config_.read([&](const Gallery_Config& value) { + config_data = to_frontend_json(value); + }); + 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"}); + Json examples = Json::object(); + examples["descriptor"] = to_descriptor_json(); + examples["form_view"] = config_resource_.view_schema(); + examples["table_view"] = rows_.view_schema(); + examples["backend_composition_view"] = backend_layout_view_json(); + examples["frontend_composition_view"] = to_view_json(make_frontend_layout_view()); + 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["items"] = rows_.items_json(); + 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", "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)}}; +} void Component_Gallery::bind(httplib::Server& server) { config_resource_.bind(server); rows_.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())); + }); + server.Get("/admin/gallery/backend/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(amis_schema())); + }); + 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/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(list_schema())); + }); + server.Get("/admin/gallery/items/cards/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(cards_schema())); + }); + server.Get("/admin/gallery/items/table/amis", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(rows_.amis_schema())); + }); + server.Get("/admin/gallery/docs", [this](const httplib::Request&, httplib::Response& response) { + write_http_response(response, make_http_success(documentation())); + }); } std::vector Component_Gallery::make_rows() { return { Gallery_Row{"Alpha", Gallery_Category::primary, Range_Value{92}, true, "#2563eb"}, Gallery_Row{"Beta", Gallery_Category::secondary, Range_Value{76}, true, "#16a34a"}, - Gallery_Row{"Gamma", Gallery_Category::experimental, Range_Value{58}, false, "#d97706"} + Gallery_Row{"Gamma", Gallery_Category::experimental, Range_Value{58}, false, "#d97706"}, + Gallery_Row{"Delta", Gallery_Category::primary, Range_Value{84}, true, "#7c3aed"}, + Gallery_Row{"Epsilon", Gallery_Category::secondary, Range_Value{67}, false, "#0891b2"}, + Gallery_Row{"Zeta", Gallery_Category::experimental, Range_Value{49}, true, "#dc2626"} }; } } diff --git a/backend/service/src/gallery.hpp b/backend/service/src/gallery.hpp index a4944bf..9bf3ac1 100644 --- a/backend/service/src/gallery.hpp +++ b/backend/service/src/gallery.hpp @@ -9,6 +9,11 @@ public: void bind(httplib::Server& server); 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 documentation() const; Managed_Value config_; Http_Resource config_resource_; Http_Collection_Resource rows_; diff --git a/backend/service/src/server_app.cpp b/backend/service/src/server_app.cpp index d0f365e..048bf93 100644 --- a/backend/service/src/server_app.cpp +++ b/backend/service/src/server_app.cpp @@ -13,6 +13,16 @@ Radio_State make_radio_state(Radio_Mode mode, int port, int buffer_count, int lo } } Server_App::Server_App(int port, std::filesystem::path frontend_dist, std::filesystem::path config_file) : port_(port), frontend_dist_(std::move(frontend_dist)), config_store_(std::move(config_file)), state_resource_("/admin/radio_states", make_radio_states()), service_config_resource_(config_store_.radio_service(), "/admin/config/radio", config_store_.radio_service_transaction()), alert_config_resource_(config_store_.alerts(), "/admin/config/alerts", config_store_.alerts_transaction()), device_tables_(config_store_) { + server_.set_file_request_handler([](const httplib::Request& request, httplib::Response& response) { + if(request.path == "/" || request.path == "/index.html") { + response.set_header("Cache-Control", "no-store, no-cache, must-revalidate"); + response.set_header("Pragma", "no-cache"); + return; + } + if(request.path.starts_with("/assets/") || request.path.starts_with("/vendor/amis/")) { + response.set_header("Cache-Control", "public, max-age=31536000, immutable"); + } + }); state_resource_.register_overview_status("/admin/status", 2000); state_resource_.register_status<&Radio_State::status>(2000); bind_routes(); diff --git a/backend/service/tests/view_schema_test.cpp b/backend/service/tests/view_schema_test.cpp index 1258f9e..d69369c 100644 --- a/backend/service/tests/view_schema_test.cpp +++ b/backend/service/tests/view_schema_test.cpp @@ -81,11 +81,41 @@ int main() { assert(flow_amis.at("body").at(0).at("type") == "flex"); assert(flow_amis.at("body").at(0).at("items").at(0).at("type") == "container"); assert(flow_amis.at("body").at(0).at("items").at(1).at("type") == "container"); + const auto list_composition = adminive::composition_view("list_page", adminive::compose::list(adminive::compose::slot("left"), adminive::compose::slot("right"))); + const auto list_composition_json = adminive::to_view_json(list_composition); + assert(list_composition_json.at("body").at("kind") == "list"); + const auto list_composition_amis = adminive::to_amis_composition_schema(list_composition, {{"left", Json{{"type", "tpl"}, {"tpl", "left"}}}, {"right", Json{{"type", "tpl"}, {"tpl", "right"}}}}); + assert(list_composition_amis.at("body").at(0).at("type") == "container"); + assert(list_composition_amis.at("body").at(0).at("body").size() == 2); + const auto frontend_composition = adminive::composition_view("frontend_page", adminive::compose::frontend(adminive::compose::slot("left"), adminive::compose::slot("right"))); + const auto frontend_composition_json = adminive::to_view_json(frontend_composition); + 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 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"); + assert(frontend_form_json.at("body").at("children").at(0).at("kind") == "list"); const auto table_amis = adminive::to_amis_table_schema("/configs", table_view); assert(table_amis.at("type") == "page"); 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 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"); + assert(!list_amis.at("body").at(0).contains("columns")); + assert(list_amis.at("body").at(0).at("listItem").at("actions").size() == 2); + assert(list_amis.at("body").at(0).at("listItem").at("actions").at(0).at("dialog").at("body").at("reload") == "view_config_table_crud_list"); + assert(list_amis.at("body").at(0).at("listItem").at("actions").at(1).at("reload") == "view_config_table_crud_list"); + assert(list_amis.at("body").at(0).at("headerToolbar").size() == 2); + assert(list_amis.at("body").at(0).at("headerToolbar").at(1).at("dialog").at("body").at("reload") == "view_config_table_crud_list"); + const auto cards_amis = adminive::to_amis_cards_schema("/configs", table_view, Json{{"header", Json{{"title", "${name}"}}}}, 2); + assert(cards_amis.at("body").at(0).at("mode") == "cards"); + assert(cards_amis.at("body").at(0).at("id") == "view_config_table_crud_cards"); + 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"); 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"; @@ -122,6 +152,20 @@ int main() { invalid_tabs = true; } assert(invalid_tabs); + bool invalid_tab_position{}; + try { + adminive::validate_form_view(adminive::edit_form(adminive::tab("Invalid", adminive::use<&Config::name>()))); + } catch(const std::invalid_argument&) { + invalid_tab_position = true; + } + assert(invalid_tab_position); + bool invalid_composition_tab_position{}; + try { + adminive::validate_composition_view(adminive::composition_view("invalid", adminive::compose::tab("Invalid", adminive::compose::slot("x")))); + } catch(const std::invalid_argument&) { + invalid_composition_tab_position = true; + } + assert(invalid_composition_tab_position); bool invalid_sort{}; try { adminive::validate_table_view(adminive::table_view(adminive::column<&Config::name>()).default_sort("name")); diff --git a/frontend/src/AmisPanel.tsx b/frontend/src/AmisPanel.tsx new file mode 100644 index 0000000..5c35685 --- /dev/null +++ b/frontend/src/AmisPanel.tsx @@ -0,0 +1,25 @@ +import {useEffect, useRef, useState} from 'react'; +import {amis_fetcher} from './amis_fetcher'; +import {embed_amis} from './amis_runtime'; +type Amis_Schema = Record; +interface Amis_Panel_Props { + schema: Amis_Schema; + className?: string; +} +export default function AmisPanel({schema, className = ''}: Amis_Panel_Props) { + const container = useRef(null); + const [error, set_error] = useState(''); + useEffect(() => { + if(!container.current) { + return; + } + set_error(''); + try { + const scoped = embed_amis(container.current, schema, amis_fetcher); + return () => scoped.unmount(); + } catch(reason) { + set_error(reason instanceof Error ? reason.message : String(reason)); + } + }, [schema]); + return
{error ?
{error}
:
}
; +} diff --git a/frontend/src/App.css b/frontend/src/App.css index b5e7dbf..ea6d308 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,118 +1,498 @@ .app-shell { - width: min(1320px, calc(100% - 32px)); - margin: 32px auto; + width: min(1540px, calc(100% - 32px)); + margin: 24px auto 48px; } -.app-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - margin-bottom: 16px; +.hero { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 32px; + padding: 28px 32px; + border: 1px solid #dbe4f0; + border-radius: 18px; + background: linear-gradient(135deg, #ffffff 0%, #f7faff 58%, #eef5ff 100%); + box-shadow: 0 14px 42px rgba(15, 23, 42, 0.07); } -.app-header h1 { - margin: 0 0 8px; - font-size: 28px; +.hero h1 { + margin: 4px 0 10px; + font-size: clamp(28px, 4vw, 46px); + line-height: 1.08; + letter-spacing: -0.04em; } -.app-header p { - margin: 0; - color: #6b7280; +.hero p { + max-width: 900px; + margin: 0; + color: #64748b; + line-height: 1.7; } -.app-header button { - flex: none; - padding: 9px 16px; - border: 1px solid #2563eb; - border-radius: 6px; - color: #fff; - background: #2563eb; - cursor: pointer; +.eyebrow { + color: #2563eb; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.16em; } -.app-header button:disabled { - cursor: default; - opacity: 0.6; +.hero button { + flex: none; + padding: 10px 17px; + border: 1px solid #1d4ed8; + border-radius: 10px; + color: #fff; + background: #2563eb; + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.24); + cursor: pointer; } -.demo-notes { - display: flex; - gap: 8px; - margin-bottom: 16px; - padding: 12px 16px; - border: 1px solid #bfdbfe; - border-radius: 8px; - background: #eff6ff; +.hero button:disabled { + cursor: default; + opacity: 0.55; } -.amis-region { - min-height: 320px; - padding: 20px; - border: 1px solid #e5e7eb; - border-radius: 10px; - background: #fff; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); +.workspace { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + gap: 20px; + margin-top: 20px; +} +.sidebar { + align-self: start; + position: sticky; + top: 18px; + display: grid; + gap: 7px; + padding: 14px; + border: 1px solid #dbe4f0; + border-radius: 16px; + background: #fff; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05); +} +.side-title { + padding: 7px 10px 10px; + color: #94a3b8; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; +} +.sidebar button { + display: grid; + gap: 2px; + width: 100%; + padding: 10px 11px; + border: 1px solid transparent; + border-radius: 10px; + text-align: left; + color: #334155; + background: transparent; + cursor: pointer; +} +.sidebar button span { + color: #94a3b8; + font-size: 12px; +} +.sidebar button.active { + border-color: #bfdbfe; + color: #1d4ed8; + background: #eff6ff; +} +.content { + min-width: 0; + padding: 22px; + border: 1px solid #dbe4f0; + border-radius: 16px; + background: #fff; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.05); +} +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; + padding-bottom: 16px; + border-bottom: 1px solid #e2e8f0; +} +.section-head span { + color: #64748b; + font-size: 12px; +} +.section-head h2 { + margin: 2px 0 0; + font-size: 26px; +} +.protocol-tags { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px; +} +.protocol-tags span { + padding: 5px 8px; + border: 1px solid #dbeafe; + border-radius: 999px; + color: #1d4ed8; + background: #f8fbff; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .state-message { - padding: 32px; - text-align: center; - color: #6b7280; + padding: 24px; + border: 1px dashed #cbd5e1; + border-radius: 12px; + color: #64748b; + text-align: center; } -.state-message.error { - color: #b91c1c; +.state-message.error, +.inline-error { + color: #b91c1c; + background: #fef2f2; } -@media (max-width: 700px) { - .app-header { - align-items: stretch; - flex-direction: column; - } - .demo-notes { - flex-direction: column; - } +.intro-panel, +.explain-box, +.demo-block { + margin-bottom: 18px; + padding: 18px; + border: 1px solid #dbe4f0; + border-radius: 14px; + background: #fbfdff; } -.adminive-status-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; +.intro-panel h3, +.demo-block h3, +.endpoint-panel h3, +.protocol-panel h3 { + margin: 0 0 8px; } -.adminive-status-grid > div { - display: flex; - flex-direction: column; - gap: 4px; - padding: 12px; - border: 1px solid #e5e7eb; - border-radius: 6px; - background: #f9fafb; +.intro-panel p, +.explain-box p { + margin: 4px 0 0; + color: #64748b; + line-height: 1.65; } -.adminive-status-grid strong { - color: #6b7280; - font-size: 12px; - font-weight: 600; - text-transform: uppercase; +.layer-flow { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; } -.adminive-status-grid span { - color: #111827; - font-size: 16px; +.layer-flow div { + display: grid; + align-content: start; + min-height: 126px; + padding: 13px; + border: 1px solid #e2e8f0; + border-radius: 12px; + background: #fff; } -@media (max-width: 800px) { - .adminive-status-grid { +.layer-flow div > span { + display: grid; + place-items: center; + width: 26px; + height: 26px; + margin-bottom: 10px; + border-radius: 8px; + color: #fff; + background: #2563eb; + font-weight: 800; +} +.layer-flow small { + margin-top: 6px; + color: #64748b; + line-height: 1.45; +} +.capability-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - } + gap: 12px; } -@media (max-width: 520px) { - .adminive-status-grid { - grid-template-columns: 1fr; - } +.capability-card { + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 12px; } -.adminive-object-status { - min-width: 300px; - padding: 4px 0; +.capability-card h3 { + margin: 0 0 10px; + font-size: 15px; } -.adminive-object-status-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - padding: 8px 4px; - border-bottom: 1px solid #e5e7eb; +.badge-list { + display: flex; + flex-wrap: wrap; + gap: 6px; } -.adminive-object-status-row:last-child { - border-bottom: 0; +.badge-list span { + padding: 4px 7px; + border-radius: 7px; + color: #475569; + background: #f1f5f9; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; } -.adminive-object-status-label { - color: #64748b; +.amis-panel { + min-width: 0; + margin-bottom: 16px; +} +.amis-runtime { + min-width: 0; +} +.inline-error { + padding: 12px; + border: 1px solid #fecaca; + border-radius: 10px; +} +.frontend-horizontal { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} +.frontend-vertical { + display: grid; + gap: 10px; +} +.slot-demo { + height: 100%; + margin: 0; + padding: 13px; + border: 1px solid #dbeafe; + border-radius: 12px; + background: #fff; +} +.native-list { + 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; + border-bottom: 1px solid #e2e8f0; +} +.native-list article: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 { + 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; + background: #fff; +} +.card-accent { + position: absolute; + inset: 0 0 auto; + height: 4px; +} +.native-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} +.native-card-head > span { + font-size: 28px; + font-weight: 800; +} +.meter { + height: 6px; + margin-top: 18px; + border-radius: 999px; + background: #e2e8f0; + overflow: hidden; +} +.meter i { + display: block; + height: 100%; +} +.native-cards p { + margin: 10px 0 0; + color: #64748b; + font-size: 13px; +} +.native-table-wrap { + overflow-x: auto; + border: 1px solid #e2e8f0; + border-radius: 12px; +} +.native-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.native-table th, +.native-table td { + padding: 11px 12px; + border-bottom: 1px solid #e2e8f0; + text-align: left; + white-space: nowrap; +} +.native-table th { + color: #475569; + background: #f8fafc; +} +.native-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; + margin-bottom: 16px; + padding: 4px; + border: 1px solid #dbe4f0; + border-radius: 10px; + background: #f8fafc; +} +.mode-switch button { + padding: 7px 14px; + border: 0; + border-radius: 7px; + color: #64748b; + background: transparent; + cursor: pointer; +} +.mode-switch button.active { + color: #1d4ed8; + background: #fff; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); +} +.json-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +.json-panel { + min-width: 0; + border: 1px solid #dbe4f0; + border-radius: 12px; + background: #0f172a; + overflow: hidden; +} +.json-panel summary { + padding: 12px 14px; + color: #dbeafe; + background: #172033; + cursor: pointer; + font-weight: 700; +} +.json-panel pre, +.protocol-panel pre { + max-height: 560px; + margin: 0; + padding: 15px; + overflow: auto; + color: #dbeafe; + background: #0f172a; + font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.doc-layout { + display: grid; + grid-template-columns: 0.85fr 1.65fr; + gap: 14px; + margin-bottom: 16px; +} +.endpoint-panel, +.protocol-panel { + min-width: 0; + padding: 16px; + border: 1px solid #e2e8f0; + border-radius: 13px; +} +.endpoint-panel code { + display: block; + margin-top: 7px; + padding: 7px 9px; + border-radius: 7px; + color: #334155; + background: #f1f5f9; + font-size: 12px; + overflow-wrap: anywhere; +} +.protocol-panel select { + width: 100%; + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #fff; +} +.protocol-panel pre { + border-radius: 9px; +} +@media (max-width: 1120px) { + .layer-flow { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .frontend-horizontal, + .native-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 820px) { + .app-shell { + width: min(100% - 18px, 1540px); + margin-top: 10px; + } + .hero, + .section-head { + align-items: stretch; + flex-direction: column; + } + .workspace { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .side-title { + grid-column: 1 / -1; + } + .layer-flow, + .capability-grid, + .frontend-horizontal, + .native-cards, + .json-grid, + .doc-layout { + grid-template-columns: 1fr; + } + .protocol-tags { + justify-content: flex-start; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4df87c..676924b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,24 +1,88 @@ import axios from 'axios'; -import {useCallback, useEffect, useRef, useState} from 'react'; -import {amis_fetcher} from './amis_fetcher'; -import {embed_amis} from './amis_runtime'; +import {useCallback, useEffect, useMemo, useState} from 'react'; +import AmisPanel from './AmisPanel'; +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 Collection_Mode = 'list' | 'cards' | 'table'; +interface Gallery_Item { + id: number; + name: string; + category: string; + score: number; + enabled: boolean; + color: string; +} +interface Documentation { + title: string; + summary: string; + layers: Array<{name: string; purpose: string}>; + capabilities: Record; + endpoints: string[]; + examples: Record; +} +interface Frontend_Manifest { + view: unknown; + slots: Record; + items: Gallery_Item[]; +} +const sections: Array<{id: Section_Id; title: string; subtitle: string}> = [ + {id: 'overview', title: '总览', subtitle: '能力地图'}, + {id: 'backend', title: '后端组合', subtitle: '后端决定定位'}, + {id: 'frontend', title: '前端定位', subtitle: '前端决定位置'}, + {id: 'form', title: '字段与表单', subtitle: '控件 + View'}, + {id: 'collection', title: '集合画廊', subtitle: '列表 / 卡片 / 表格'}, + {id: 'runtime', title: '完整运行时', subtitle: '状态 + 配置 + 多类型'}, + {id: 'docs', title: '协议文档', subtitle: 'JSON + API'} +]; +function as_schema(value: unknown): Amis_Schema { + return value as Amis_Schema; +} +async function fetch_data(url: string): Promise { + const response = await axios.get>(url, {headers: {'Cache-Control': 'no-cache'}}); + if(response.data.status !== 0) { + throw new Error(response.data.msg || `请求失败:${url}`); + } + return response.data.data; +} +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 NativeList({items}: {items: Gallery_Item[]}) { + return
{items.map(item =>
{item.name}{item.category} · 评分 {item.score}
{item.enabled ? '启用' : '停用'}
)}
; +} +function NativeCards({items}: {items: Gallery_Item[]}) { + return
{items.map(item =>
{item.name}{item.category}
{item.score}

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

)}
; +} +function NativeTable({items}: {items: Gallery_Item[]}) { + return
{items.map(item => )}
ID名称分类评分状态颜色
{item.id}{item.name}{item.category}{item.score}{item.enabled ? '启用' : '停用'}{item.color}
; +} export default function App() { - const [schema, set_schema] = useState(); + const [section, set_section] = useState('overview'); + const [collection_mode, set_collection_mode] = useState('list'); + 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 amis_container = useRef(null); - const load_schema = useCallback(async () => { + const [json_example, set_json_example] = useState('descriptor'); + const load_all = useCallback(async () => { set_loading(true); set_error(''); try { - const response = await axios.get>('/admin/amis'); - if(response.data.status !== 0) { - throw new Error(response.data.msg || '加载后端页面描述失败'); - } - set_schema(response.data.data); + const [next_docs, next_manifest, next_full_schema] = await Promise.all([ + fetch_data('/admin/gallery/docs'), + fetch_data('/admin/gallery/frontend/manifest'), + fetch_data('/admin/amis') + ]); + set_docs(next_docs); + set_manifest(next_manifest); + set_full_schema(next_full_schema); } catch(reason) { set_error(reason instanceof Error ? reason.message : String(reason)); } finally { @@ -26,33 +90,14 @@ export default function App() { } }, []); useEffect(() => { - void load_schema(); - }, [load_schema]); - useEffect(() => { - if(!schema || !amis_container.current || loading || error) { - return; + void load_all(); + }, [load_all]); + const collection_schema = useMemo(() => { + if(!docs) { + return undefined; } - try { - const scoped = embed_amis(amis_container.current, schema, amis_fetcher); - return () => scoped.unmount(); - } catch(reason) { - set_error(reason instanceof Error ? reason.message : String(reason)); - } - }, [schema, loading, error]); - return ( -
-
-
-

Adminive

-

组件画廊、字段控件、布局组合、表格操作、状态刷新和配置页面均由后端 JSON 描述驱动。

-
- -
-
- {loading &&
正在加载后端描述……
} - {!loading && error &&
{error}
} - {!loading && !error &&
} -
-
- ); + return as_schema(docs.examples[`${collection_mode}_amis`]); + }, [collection_mode, docs]); + 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)}
}}
; } diff --git a/frontend/src/JsonPanel.tsx b/frontend/src/JsonPanel.tsx new file mode 100644 index 0000000..cf7dd33 --- /dev/null +++ b/frontend/src/JsonPanel.tsx @@ -0,0 +1,8 @@ +interface Json_Panel_Props { + title: string; + value: unknown; + open?: boolean; +} +export default function JsonPanel({title, value, open = false}: Json_Panel_Props) { + return
{title}
{JSON.stringify(value, null, 2)}
; +} diff --git a/frontend/src/amis_runtime.ts b/frontend/src/amis_runtime.ts new file mode 100644 index 0000000..01b616b --- /dev/null +++ b/frontend/src/amis_runtime.ts @@ -0,0 +1,20 @@ +import type {Amis_Request} from './types'; +type Amis_Fetcher = (request: Amis_Request) => Promise; +export interface Amis_Scoped { + unmount(): void; +} +interface Amis_Embed_Module { + embed(container: HTMLElement | string, schema: Record, props?: Record, env?: Record): Amis_Scoped; +} +declare global { + interface Window { + amisRequire?: (name: string) => unknown; + } +} +export function embed_amis(container: HTMLElement, schema: Record, fetcher: Amis_Fetcher): Amis_Scoped { + if(!window.amisRequire) { + throw new Error('AMIS SDK runtime is not loaded'); + } + const module = window.amisRequire('amis/embed') as Amis_Embed_Module; + return module.embed(container, schema, {}, {fetcher, theme: 'cxd'}); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 95f407c..f079acd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,18 +1,19 @@ :root { - font-family: Inter, "Microsoft YaHei", sans-serif; - color: #1f2937; - background: #f3f4f6; - font-synthesis: none; - text-rendering: optimizeLegibility; + font-family: Inter, "Microsoft YaHei", "PingFang SC", sans-serif; + color: #0f172a; + background: #f1f5f9; + font-synthesis: none; + text-rendering: optimizeLegibility; } * { - box-sizing: border-box; + box-sizing: border-box; } body { - margin: 0; - min-width: 320px; - min-height: 100vh; + margin: 0; + min-width: 320px; + min-height: 100vh; } -button { - font: inherit; +button, +select { + font: inherit; }