Skip to content

门户 ​

适用端:后端 + Web | 类型:机制

定制功能

门户(infra_portal)是本项目自研能力,上游 yudao 的首页是写死的静态工作台。

门户把「登录后看到什么」变成一份可配置的布局 JSON:24 列 CSS Grid,上面摆卡片。它有两种类型 —— 在线设计(拖拽出来的布局)和自定义链接(直接跳一个路由或外链)。本页讲清楚草稿与发布快照怎么分离、组件白名单为什么是两份、以及运行面为什么刻意不挂权限。

前置阅读:路由与权限机制、数据接口(卡片取数走它)。

它解决什么问题 ​

代码在哪 ​

后端前缀 enlev-module-infra/src/main/java/cn/enlev/ainb/module/infra/,前端前缀 ainb-ui-admin-vue3/src/:

职责文件关键方法
HTTP 入口(设计面 + 运行面)controller/admin/portal/PortalController.java/infra/portal,17 个端点
服务service/portal/PortalServiceImpl.javapublish:221、rollback:268、getPublishedVersion:289、isPortalVisible:376
布局 JSON 校验service/portal/PortalConfigValidator.javavalidate:28、isSupportedType:148
DO / Mapperdal/dataobject/portal/{PortalDO, PortalVersionDO}.java、dal/mysql/portal/{PortalMapper, PortalVersionMapper}.javaselectDefault:27、selectMaxVersionByPortalId:25
枚举enums/portal/{PortalStatusEnum, PortalTypeEnum, PortalVisibleScopeEnum}.java—
门户流程数据enlev-module-bpm/.../controller/admin/portal/BpmPortalDataController.java/bpm/portal/summary、/bpm/portal/tasks
门户公告数据enlev-module-system/.../controller/admin/portal/SystemPortalDataController.java/system/portal/notices
组件清单(前端权威)components/Portal/helper/componentRegistry.tsPORTAL_COMPONENTS:272、parsePortalConfig:1618、normalizeItem:858
运行时渲染components/Portal/Runtime/{PortalRuntime, PortalWidget, PortalCardHeader, PortalLinkFrame, PortalNoticeDetail}.vue—
设计器components/Portal/Designer/{PortalDesigner, PortalPalette, PortalPropertyPanel, PortalContainerAdd, ...}.vue—
管理页 / 设计器页views/infra/portal/{index.vue, PortalForm.vue, editor/index.vue, components/VersionDrawer.vue}—
首页入口views/Home/Index.vueloadPortal:432、initHome:455
门户切换器layout/components/Setting/src/Setting.vue:12,22-40—

核心机制 ​

布局协议 ​

{ schemaVersion: 1, columns: 24, rowHeight, gap, background,
  items: [{ id, type, x, y, w, h, props }] }

协议定义在 api/infra/portal/index.ts:3-20 与 componentRegistry.ts:833-840。渲染成 24 列 CSS Grid,每个 item 用 gridColumn / gridRow 定位(PortalRuntime.vue:37-46)。

schemaVersion 当前恒为 1(PortalConfigValidator.SCHEMA_VERSION:21),校验时不匹配直接拒绝(:46-50)。字段已就位但没有迁移逻辑 —— 将来升到 2 时旧快照会全部读不出来。

PortalConfigValidator 的约束清单(:21-26,51,59,71-102):

约束值
content 大小≤ 256 KB
columns必须等于 24
rowHeight∈ [1, 100]
gap∈ [0, 64]
单层 items≤ 50
含嵌套总控件≤ 100
嵌套深度≤ 4
id≤ 64 字符且全局(含嵌套)不可重复
坐标x + w ≤ 24,必须是整数(不接受小数)

版本机制:草稿与发布快照分离 ​

status 三态(PortalStatusEnum):0 草稿 / 1 已发布 / 2 已发布有改动。update 时若已发布且新草稿内容等于当前发布快照 → 回到 1,否则 → 2(:119-127)。

三条容易记反的:

  • 回滚只改草稿:rollback:266-286 把 draft_content 覆盖成目标版本的 content,不产生新版本、不动 published_version,再按内容比对决定 status。要让线上生效得再点一次发布。
  • 下线:unpublish:252-264 把 status→0、published_version→0。
  • 运行时读版本快照不读草稿(toRuntime:205 → getPublishedVersion:289-295)。preview 是唯一例外,直接返回 draftContent(PortalController.java:129-142)。

链接门户(type=1)没有版本快照:publish 只改 status 并返回 0(:223-231),toRuntime 里 version 硬编码 0(PortalController.java:198)。

门户的版本机制与表单的不是同一套代码

表单有独立的 service/formdesign/FormDesignVersionServiceImpl.java(createVersion:43),快照是「配置字段拼成的 Map」(:64-89,刻意不含 status / publishedVersion / menuId);MAX_KEEP_VERSIONS 定义在 FormDesignVersionDO.java:39。门户把版本逻辑内联在 PortalServiceImpl 里,自己另定义一份 MAX_KEEP_VERSIONS = 100(:38),快照是单一 content 列。两者数值相同、模式相似(max+1 再 trim),但无共享代码、无共享抽象。改一处不会带动另一处。

组件白名单是两份,必须手工对齐 ​

位置数量
前端权威componentRegistry.ts:272-816 的 PORTAL_COMPONENTS22 个条目,分 4 组 layout / basic / system / chart(:18-27)
后端白名单PortalConfigValidator.isSupportedType:148-16226 个字面量

每条前端条目含 type / label / icon / description / group / defaultSize / defaultProps。

后端多出来的是 3 个历史别名 shortcut / icon / todo(另有 chart.bar / chart.line / chart.pie / chart.radar 这类带点的类型,前端按 group 组织)。别名归一在前端:componentAliases = { shortcut→commonFunc, icon→commonFunc, todo→myTodo }(:818-824)—— 后端接受旧类型名,前端渲染时统一。PortalWidget.vue:290 也保留了 item.type === 'todo' 的兼容分支。

两份清单没有生成关系。新增卡片漏改后端 → 保存/发布被 PORTAL_CONFIG_INVALID「不支持的组件类型」拒绝。

渲染链路 ​

  1. 路由 /index → views/Home/Index.vue:1-7:type===1 走 PortalLinkFrame,否则 PortalRuntime,两者都空才回退内置的静态工作台(initHome:455-458)。
  2. loadPortal():432-447:优先用户在「项目配置」抽屉里选的 appStore.homePortalCode → GET /infra/portal/runtime/{code};返回 null(下线/不可见)则回退 GET /infra/portal/runtime/home。:450-453 监听偏好变化即时刷新。
  3. PortalRuntime.vue:32:parsePortalConfig(props.portal.content) → componentRegistry.ts:1618-1640,逐项 normalizeItem:858-898(补默认 props、钳制 x∈[0,23] / w∈[1,24-x]、类型归一)。
  4. PortalRuntime.vue:9-17:v-for 渲染 PortalWidget,itemStyle:43-46 算网格位置;:80-99 移动端(≤768px)强制单列。
  5. PortalWidget.vue:34-660:按 item.type 的大 v-if/v-else-if 链分派 —— card:34、tabs:60、text:107、image:141、carousel:188、video:222、iframe:242、commonFunc:253、myTodo|todo:290、todoList|doingList:340、notice:362、email:438、dataBoard:455、tableList:508、rankList:564、timeAxis:605;图表类走 Echart 组件 + extractChartData:868,customEcharts:1402、mapChart:1428。
  6. 动态取数:PortalWidget.vue:949 loadData(),当 interfaceCode 非空且 dataMode !== 'static' 时调 invokePortalData:970 → POST /infra/portal/runtime/data(设计器态 props.designer 直接 return,:960)。系统类卡片另调 getPortalTaskSummary / getPortalTasks / getPortalNotices(:673-678)。
  7. 设计器:隐藏路由 /infra-portal/portal/editor(router/modules/remaining.ts:130-152,activeMenu: '/infra/online/portal')→ views/infra/portal/editor/index.vue:109 挂 PortalDesigner.vue;控件库 PortalPalette.vue(拖拽 dataTransfer.setData('portal/type', type),:53-56;落点在 PortalWidget.vue:765 读取);属性面板 PortalPropertyPanel.vue 按 item.type 分支;保存 stringifyPortalConfig:609 → create/update,发布 publishPortal:624。

权限:管理面挂权限,运行面刻意不挂 ​

管理面(增删改查/发布/下线/预览/回滚)逐方法 @ss.hasPermission('infra:portal:*')。

运行面 4 个端点 —— /runtime/home、/runtime/list、/runtime/{code}、/runtime/data —— 都没有 @PreAuthorize(已核实:PortalController.java:144,156,163,172 只有 Mapping 注解),只要登录态。理由逐字(:147-148):

仅需登录态:能否看到由门户「可见范围」决定,而非 infra:portal:run 权限。否则「所有人可见」的默认门户对未授予该菜单权限的用户(如演示角色)会静默不显示。

可见性由 visible_scope(0 所有人 / 1 指定角色 / 2 指定用户)+ visible_ids(角色存 code、用户存 id,逗号分隔)决定,判定在 PortalServiceImpl.isPortalVisible:376-403;super_admin 直通(:41,387,用字面量是因为 infra 不依赖 system)。PermissionCommonApi 用 ObjectProvider 注入,不可用时「指定角色」范围按不可见处理并打 WARN(:49-54,393-398)—— 这个方向是 fail-close。

不同用户看到的门户可以不同:首页取的是 is_default=true 的那一个(PortalMapper.selectDefault:27-33,还需 enabled=true 且 status IN (1,2)),再叠加可见性判定;用户可在 Setting 抽屉里改自己的偏好门户(Setting.vue:22-40,存 appStore.homePortalCode)。默认门户全局唯一:clearDefaultPortal:317-322 把其它门户的 is_default 置 false。

租户套餐不参与门户可见性:PortalServiceImpl 里没有任何套餐/package 检查;隔离只靠 TenantBaseDO + uk_tenant_code。

门户能渲染 ≠ 卡片能取数

/infra/portal/runtime/* 只要登录态,但卡片背后的 /bpm/portal/summary、/bpm/portal/tasks、/system/portal/notices 都要 infra:portal:run(BpmPortalDataController.java:49,77;SystemPortalDataController.java:35)。没有该权限的用户会看到门户框架、但待办/公告卡片取数失败。

菜单:基础设施(/infra, id 2) → 在线开发(online, id 9000050903) → 门户设计(portal, id 2540, component infra/portal/index)(SQL:33244,33920,33585)。

数据模型 ​

infra_portal ​

DDL ainb/sql/mysql/ainb-init.sql:26945。唯一索引 uk_tenant_code(tenant_id,code);普通索引 idx_tenant_status(tenant_id,status,enabled)。

列说明
draft_contentmediumtext,草稿布局 JSON
published_versionint,当前发布版本号
statustinyint,0/1/2
schema_versionint,布局协议版本(恒 1)
type0 在线设计 / 1 链接
link_type0 内部路由 / 1 外链
link_urlvarchar(1024)
is_default / enabled / sort默认门户全局唯一
visible_scope / visible_ids可见范围,visible_ids varchar(2000)

infra_portal_version ​

DDL SQL:27016。唯一索引 uk_tenant_portal_version(tenant_id,portal_id,version);普通索引 idx_tenant_portal(tenant_id,portal_id)。列:content(mediumtext,发布时的完整布局 JSON)、schema_version、publish_remark varchar(500)。

DDL 列注释有乱码,不要引用

infra_portal 的 type / link_type / link_url / visible_scope / visible_ids 五列 COMMENT 是 mojibake(已核实,SQL:26950-26952,26959-26960,例如 type 的注释原文是 '闂ㄦ埛绫诲瀷锛?-鍦ㄧ嚎璁捐? 1-鑷?畾涔夐摼鎺')。docs/.generated/tables-lowcode.md 原样继承了这些乱码。 写字段语义必须看 dal/dataobject/portal/PortalDO.java:26-42 的 Java 注释。

接口清单 ​

逐字取自各 Controller 的 @Operation / @PreAuthorize 注解。

/infra/portal(PortalController) ​

方法路径权限串摘要
POST/createinfra:portal:create创建门户
PUT/updateinfra:portal:update更新门户
DELETE/deleteinfra:portal:delete删除门户
POST/copyinfra:portal:create复制门户
GET/getinfra:portal:query获取门户
GET/pageinfra:portal:query分页
GET/check-codeinfra:portal:query校验门户编码
POST/publishinfra:portal:publish发布
POST/unpublishinfra:portal:publish下线
GET/version-listinfra:portal:query版本列表
GET/get-versioninfra:portal:query获取指定版本
POST/rollbackinfra:portal:rollback回滚门户草稿
GET/previewinfra:portal:preview预览草稿
GET/runtime/home无当前用户首页门户
GET/runtime/list无当前用户可见的已发布门户列表
POST/runtime/data无调用门户数据接口
GET/runtime/{code}无获取已发布门户

门户取数子接口(要 infra:portal:run) ​

方法路径权限串摘要
GET/bpm/portal/summaryinfra:portal:run门户流程统计
GET/bpm/portal/tasksinfra:portal:run门户流程列表
GET/system/portal/noticesinfra:portal:run门户公告列表

怎么扩展 ​

新增一种卡片 ​

  1. components/Portal/helper/componentRegistry.ts:PORTAL_COMPONENTS:272 加条目(type / label / icon / description / group / defaultSize / defaultProps)。
  2. 同文件:若该卡片支持接口取数,加进 PORTAL_DATA_SOURCE_TYPES:176-198、portalMappingDefaults:905-1000(字段映射默认值)、normalizeItemProps:1002 里加清洗分支。
  3. components/Portal/Runtime/PortalWidget.vue:在 :34-660 的 v-else-if 链里加渲染分支;若走 ECharts 还要在 extractChartData:868 加数据整形。
  4. components/Portal/Designer/PortalPropertyPanel.vue:加属性表单分支(必要时新建 Designer/XxxSettings.vue,参考 PortalChartSettings.vue / PortalNoticeSettings.vue / PortalTodoSettings.vue)。
  5. 🔴 后端 service/portal/PortalConfigValidator.java:148-162 isSupportedType 加字面量 —— 漏了这一步,保存/发布会被 PORTAL_CONFIG_INVALID「不支持的组件类型」拒绝。若是容器型(可嵌套子控件),还要在 :104-119 加递归校验分支。
  6. 补 src/test/java/.../service/portal/PortalConfigValidatorTest.java(参考 validate_systemPortalComponents:40)。

测试 ​

测试类@Test覆盖
PortalConfigValidatorTest17合法配置、嵌套 card/tabs、常用功能、系统控件、空/非法 JSON、schemaVersion、不支持的组件、越界、columns≠24、id 重复(含嵌套)、嵌套超深、缺 props、小数坐标、超数量
PortalServiceImplTest5存草稿与状态、草稿匹配发布快照时标记已发布、发布产生下一版本、有改动时仍返回发布快照、回滚到当前发布版本时标记已发布
BpmPortalDataControllerTest(bpm 模块)4todo/doing 映射、空列表、pageSize 钳制、summary 五个计数

缺口:PortalController 无测试(含 isPortalVisible 的 Controller 侧接线、toRuntime 的链接门户分支);SystemPortalDataController 无测试;前端 58 个 .spec.ts 中没有一个覆盖 components/Portal/** 或 views/infra/portal/**(已 grep 验证)。isPortalVisible 的三条分支(ALL / ROLE / USER + super_admin 直通 + PermissionCommonApi 不可用)在 PortalServiceImplTest 的 5 个方法名里看不到,判定为未覆盖。

坑与注意 ​

  1. DDL 列注释乱码 —— 见上面「数据模型」的 danger 块。
  2. 前后端组件白名单是两份,无生成关系。
  3. type 创建后不可改(PortalServiceImpl.java:88-91,:105 把 update.setType(null) 让 updateById 跳过);已发布后 code 不可改(:99-101)—— 因为用户偏好、外链都按 code 引用。
  4. update 里 visibleScope == null 表示「调用方未提交可见范围」(如设计器保存),靠 updateById 跳过 null 沿用原值(:109-113);而 visibleScope=ALL 时 visible_ids 写空串而非 null,否则从「指定角色」改回「所有人」时旧名单清不掉(:354-357)。
  5. getPublishedVersion:289-295 与 getPublishedVersionSnapshot:297-302 看着重复但语义不同:前者在 status=DRAFT 时返回 null(运行时用),后者不看 status(内容比对用)。
  6. Setting.vue:22-23 的注释与代码不符:注释说「无门户访问权限(infra:portal:run)时接口返回空列表,整个区块隐藏」,但 /infra/portal/runtime/list 实际上没有任何 @PreAuthorize。列表为空只可能因为可见性过滤或没有已发布门户。
  7. /runtime/data 强制只读:走 dataInterfaceService.invokeReadOnly,调用方改不了 SQL / 地址 / 脚本(PortalController.java:166-169)。
  8. deleteOldestVersions 用 .last("LIMIT " + count) 字符串拼接后逐条 deleteById,不是批量 DELETE(PortalVersionMapper.java:33-39);count 来自内部计算(versions.size() - 100)而非用户输入。
  9. copyPortal 的编码冲突处理:先试 {code}_copy,再试 _copy2.._copy99,全占了抛 PORTAL_CODE_DUPLICATE(:324-336);副本一律回到草稿态、is_default=false、published_version=0(:156-158)。
  10. deletePortal 会连带物理删除该门户的全部版本快照(:143-145),不可恢复。
  11. 前端 parsePortalConfig 强制 columns: 24(componentRegistry.ts:1630),且 JSON 解析失败静默回退成默认空配置(:1637-1639)—— 坏数据表现为「门户空了」而不是报错。
  12. normalizeItem 会钳制坐标(x∈[0,23]、w∈[1,24-x],:883-888),所以后端校验通过的布局在前端仍可能被微调;反向(前端能画、后端拒绝)不会发生,因为校验更严。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

未证实 ​

  • 门户与租户套餐(system_tenant_package)是否有关联,未证实:PortalServiceImpl / PortalController 里没有任何套餐检查代码;但门户设计菜单(id 2540)是否被某些套餐排除,取决于运行时套餐-菜单绑定数据,未查。
  • is_default=true 的门户被删掉或下线后首页会发生什么,代码里找不到兜底测试。链路能读通:selectDefault:27-33 查不到返回 null → runtimeHome:149-152 对 null 调 isPortalVisible(:377 有 portal == null 保护,返回 false)→ 返回 success(null) → 前端回退内置工作台。但没有测试覆盖这条路径。
  • 多个门户同时 is_default=true 的可能性:clearDefaultPortal:317-322 在 create/update 时清旧,但没有唯一索引兜底(infra_portal 只有 uk_tenant_code)。并发创建两个默认门户是否会短暂共存,未验证。
  • PortalWidget.vue 有 2817 行、PortalPropertyPanel.vue 1589 行、PortalChartSettings.vue 1489 行。只核对了类型分派骨架、取数入口和归一化逻辑,各卡片内部的渲染细节(如 mapChart 的下钻、notice 的三种 styleType)未逐行核对。

相关页面 ​