门户
适用端:后端 + 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.java | publish:221、rollback:268、getPublishedVersion:289、isPortalVisible:376 |
| 布局 JSON 校验 | service/portal/PortalConfigValidator.java | validate:28、isSupportedType:148 |
| DO / Mapper | dal/dataobject/portal/{PortalDO, PortalVersionDO}.java、dal/mysql/portal/{PortalMapper, PortalVersionMapper}.java | selectDefault: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.ts | PORTAL_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.vue | loadPortal: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_COMPONENTS | 22 个条目,分 4 组 layout / basic / system / chart(:18-27) |
| 后端白名单 | PortalConfigValidator.isSupportedType:148-162 | 26 个字面量 |
每条前端条目含 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「不支持的组件类型」拒绝。
渲染链路
- 路由
/index→views/Home/Index.vue:1-7:type===1走PortalLinkFrame,否则PortalRuntime,两者都空才回退内置的静态工作台(initHome:455-458)。 loadPortal():432-447:优先用户在「项目配置」抽屉里选的appStore.homePortalCode→GET /infra/portal/runtime/{code};返回 null(下线/不可见)则回退GET /infra/portal/runtime/home。:450-453监听偏好变化即时刷新。PortalRuntime.vue:32:parsePortalConfig(props.portal.content)→componentRegistry.ts:1618-1640,逐项normalizeItem:858-898(补默认 props、钳制x∈[0,23]/w∈[1,24-x]、类型归一)。PortalRuntime.vue:9-17:v-for渲染PortalWidget,itemStyle:43-46算网格位置;:80-99移动端(≤768px)强制单列。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。- 动态取数:
PortalWidget.vue:949 loadData(),当interfaceCode非空且dataMode !== 'static'时调invokePortalData:970→POST /infra/portal/runtime/data(设计器态props.designer直接 return,:960)。系统类卡片另调getPortalTaskSummary/getPortalTasks/getPortalNotices(:673-678)。 - 设计器:隐藏路由
/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_content | mediumtext,草稿布局 JSON |
published_version | int,当前发布版本号 |
status | tinyint,0/1/2 |
schema_version | int,布局协议版本(恒 1) |
type | 0 在线设计 / 1 链接 |
link_type | 0 内部路由 / 1 外链 |
link_url | varchar(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 | /create | infra:portal:create | 创建门户 |
| PUT | /update | infra:portal:update | 更新门户 |
| DELETE | /delete | infra:portal:delete | 删除门户 |
| POST | /copy | infra:portal:create | 复制门户 |
| GET | /get | infra:portal:query | 获取门户 |
| GET | /page | infra:portal:query | 分页 |
| GET | /check-code | infra:portal:query | 校验门户编码 |
| POST | /publish | infra:portal:publish | 发布 |
| POST | /unpublish | infra:portal:publish | 下线 |
| GET | /version-list | infra:portal:query | 版本列表 |
| GET | /get-version | infra:portal:query | 获取指定版本 |
| POST | /rollback | infra:portal:rollback | 回滚门户草稿 |
| GET | /preview | infra:portal:preview | 预览草稿 |
| GET | /runtime/home | 无 | 当前用户首页门户 |
| GET | /runtime/list | 无 | 当前用户可见的已发布门户列表 |
| POST | /runtime/data | 无 | 调用门户数据接口 |
| GET | /runtime/{code} | 无 | 获取已发布门户 |
门户取数子接口(要 infra:portal:run)
| 方法 | 路径 | 权限串 | 摘要 |
|---|---|---|---|
| GET | /bpm/portal/summary | infra:portal:run | 门户流程统计 |
| GET | /bpm/portal/tasks | infra:portal:run | 门户流程列表 |
| GET | /system/portal/notices | infra:portal:run | 门户公告列表 |
怎么扩展
新增一种卡片
components/Portal/helper/componentRegistry.ts:PORTAL_COMPONENTS:272加条目(type/label/icon/description/group/defaultSize/defaultProps)。- 同文件:若该卡片支持接口取数,加进
PORTAL_DATA_SOURCE_TYPES:176-198、portalMappingDefaults:905-1000(字段映射默认值)、normalizeItemProps:1002里加清洗分支。 components/Portal/Runtime/PortalWidget.vue:在:34-660的v-else-if链里加渲染分支;若走 ECharts 还要在extractChartData:868加数据整形。components/Portal/Designer/PortalPropertyPanel.vue:加属性表单分支(必要时新建Designer/XxxSettings.vue,参考PortalChartSettings.vue/PortalNoticeSettings.vue/PortalTodoSettings.vue)。- 🔴 后端
service/portal/PortalConfigValidator.java:148-162 isSupportedType加字面量 —— 漏了这一步,保存/发布会被PORTAL_CONFIG_INVALID「不支持的组件类型」拒绝。若是容器型(可嵌套子控件),还要在:104-119加递归校验分支。 - 补
src/test/java/.../service/portal/PortalConfigValidatorTest.java(参考validate_systemPortalComponents:40)。
测试
| 测试类 | @Test | 覆盖 |
|---|---|---|
PortalConfigValidatorTest | 17 | 合法配置、嵌套 card/tabs、常用功能、系统控件、空/非法 JSON、schemaVersion、不支持的组件、越界、columns≠24、id 重复(含嵌套)、嵌套超深、缺 props、小数坐标、超数量 |
PortalServiceImplTest | 5 | 存草稿与状态、草稿匹配发布快照时标记已发布、发布产生下一版本、有改动时仍返回发布快照、回滚到当前发布版本时标记已发布 |
BpmPortalDataControllerTest(bpm 模块) | 4 | todo/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 个方法名里看不到,判定为未覆盖。
坑与注意
- DDL 列注释乱码 —— 见上面「数据模型」的 danger 块。
- 前后端组件白名单是两份,无生成关系。
type创建后不可改(PortalServiceImpl.java:88-91,:105把update.setType(null)让updateById跳过);已发布后code不可改(:99-101)—— 因为用户偏好、外链都按 code 引用。update里visibleScope == null表示「调用方未提交可见范围」(如设计器保存),靠updateById跳过 null 沿用原值(:109-113);而visibleScope=ALL时visible_ids写空串而非 null,否则从「指定角色」改回「所有人」时旧名单清不掉(:354-357)。getPublishedVersion:289-295与getPublishedVersionSnapshot:297-302看着重复但语义不同:前者在status=DRAFT时返回 null(运行时用),后者不看 status(内容比对用)。Setting.vue:22-23的注释与代码不符:注释说「无门户访问权限(infra:portal:run)时接口返回空列表,整个区块隐藏」,但/infra/portal/runtime/list实际上没有任何@PreAuthorize。列表为空只可能因为可见性过滤或没有已发布门户。/runtime/data强制只读:走dataInterfaceService.invokeReadOnly,调用方改不了 SQL / 地址 / 脚本(PortalController.java:166-169)。deleteOldestVersions用.last("LIMIT " + count)字符串拼接后逐条deleteById,不是批量 DELETE(PortalVersionMapper.java:33-39);count来自内部计算(versions.size() - 100)而非用户输入。copyPortal的编码冲突处理:先试{code}_copy,再试_copy2.._copy99,全占了抛PORTAL_CODE_DUPLICATE(:324-336);副本一律回到草稿态、is_default=false、published_version=0(:156-158)。deletePortal会连带物理删除该门户的全部版本快照(:143-145),不可恢复。- 前端
parsePortalConfig强制columns: 24(componentRegistry.ts:1630),且 JSON 解析失败静默回退成默认空配置(:1637-1639)—— 坏数据表现为「门户空了」而不是报错。 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.vue1589 行、PortalChartSettings.vue1489 行。只核对了类型分派骨架、取数入口和归一化逻辑,各卡片内部的渲染细节(如 mapChart 的下钻、notice 的三种 styleType)未逐行核对。
相关页面
- 数据接口 ——
/runtime/data背后的invokeReadOnly - 表单运行时与扩展点 —— 另一套「设计态 / 运行态」分离,但版本机制不共用
- 前端工程结构 —— 路由与组件解析
- 使用手册 02 基础设施 · 门户设计 —— 界面上长什么样
