feat(portal): docs/16 首页布局后端- home_layout零DDL消费与主题fragment化-PortalSiteSnapshot.homeLayout+normalizeHomeLayout脏数据容错;PortalPageRenderer.buildLayoutBlocks按区块序列渲染(article_list多栏目聚合+homeStyle别名+category合成+background回落news_bg_image/ticker top|category双源/gallery标题副题缺省旧键/links分组过滤/visible跳过/未知type跳过/size 1~20夹取);布局模式清空topArticles/focusArticles/categoryBlocks/galleryBanners回落段;SiteService.validateConfigJson带siteId收紧home_layout(blocks<=30/type白名单/size 1~20/文本<=100/categoryIds归属校验:新建拒跨站拒不存在拒);themes/common/fragments/home-blocks.html plb-*兜底骨架(不注册皮肤包,Thymeleaf classpath可解析);campus-glow自有fragment(news_grid/media_focus/card/ticker/gallery)原home.html抽出;default+blue-white+classic-red+fresh-mint+mint-breeze+warm-kinder+scholar-blue+campus-glow八套home.html接入布局分支(th:case父元素+th:replace子元素嵌套,replace优先级100先于case执行);新增PortalHomeLayoutIT 5+SiteServiceHomeLayoutIT 3,全量330绿
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-10 15:08:40 +08:00
parent 98d640faba
commit 39454e4efb
15 changed files with 1233 additions and 143 deletions
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
@@ -37,7 +38,9 @@ public class PortalPageRenderer {
private final CmsArticleRepository articleRepository;
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url);
model.addAttribute("site", site);
@@ -48,10 +51,20 @@ public class PortalPageRenderer {
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners);
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId()));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
if (layoutBlocks != null) {
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
model.addAttribute("layoutBlocks", layoutBlocks);
model.addAttribute("galleryBanners", new ArrayList<>());
model.addAttribute("topArticles", new ArrayList<>());
model.addAttribute("focusArticles", new ArrayList<>());
model.addAttribute("categoryBlocks", new ArrayList<>());
} else {
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
}
model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description"));
@@ -169,10 +182,10 @@ public class PortalPageRenderer {
}
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId) {
private List<PortalListData.Item> topArticles(Integer siteId, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_TOP_SIZE,
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
@@ -248,6 +261,250 @@ public class PortalPageRenderer {
}
}
// ===== 首页布局装配(docs/16 主线B §4.4)=====
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
*/
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
PortalUrlHelper url) {
if (homeLayout == null) {
return null;
}
Object raw = homeLayout.get("blocks");
if (!(raw instanceof List)) {
return null; // 容错:blocks 非数组视为无布局
}
Set<Integer> treeIds = new java.util.HashSet<>();
collectTreeIds(site.getCategories(), treeIds);
List<Map<String, Object>> result = new ArrayList<>();
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<String, Object> spec = (Map<String, Object>) o;
if (Boolean.FALSE.equals(spec.get("visible"))) {
continue; // 编辑器「隐藏」:保序下线
}
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
if (block != null) {
result.add(block);
}
}
return result;
}
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
for (PortalCategory c : categories) {
ids.add(c.getId());
collectTreeIds(c.getChildren(), ids);
}
}
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
switch (type) {
case "article_list":
return buildArticleListBlock(site, spec, treeIds, url);
case "ticker":
return buildTickerBlock(site, spec, treeIds);
case "gallery":
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题) */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
title = fallbackTitle;
}
block.put("title", title);
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
return block;
}
/**
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
*/
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
List<Integer> categoryIds = new ArrayList<>();
PortalCategory first = null;
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node == null) {
continue;
}
collectListDescendantIds(node, categoryIds);
if (first == null) {
first = node;
}
}
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
List<PortalListData.Item> items = categoryIds.isEmpty()
? new ArrayList<>()
: articlesIn(site.getId(), categoryIds, size);
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
Map<String, Object> block = baseBlock(spec, "article_list",
first != null ? first.getName() : "栏目文章");
block.put("style", style);
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
block.put("items", items);
block.put("size", size);
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
boolean background = spec.get("background") != null
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
: hasText(configValue(site, "news_bg_image"));
block.put("background", background);
return block;
}
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
List<PortalListData.Item> items;
if ("category".equals(source)) {
List<Integer> categoryIds = new ArrayList<>();
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node != null) {
collectListDescendantIds(node, categoryIds);
}
}
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
} else {
items = topArticles(site.getId(), size);
}
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
block.put("source", source);
block.put("items", items);
block.put("size", size);
return block;
}
/**
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
*/
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
block.put("subtitle", subtitle);
block.put("items", activeBanners(site, "gallery", url));
return block;
}
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
if (group.isEmpty() || group.equals(g.getGroupName())) {
groups.add(g);
}
}
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
block.put("group", group);
block.put("linkGroups", groups);
return block;
}
// ===== 布局装配工具 =====
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private static String configValue(PortalSiteSnapshot site, String key) {
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
return v == null ? null : String.valueOf(v);
}
private static List<Integer> intList(Object v) {
List<Integer> ids = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<?>) v) {
if (o instanceof Number && ((Number) o).intValue() > 0) {
ids.add(((Number) o).intValue());
}
}
}
return ids;
}
private static int clampSize(Object v, int fallback) {
if (!(v instanceof Number)) {
return fallback;
}
int n = ((Number) v).intValue();
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
}
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
for (PortalCategory c : categories) {
if (c.getId().equals(id)) {
return c;
}
PortalCategory hit = findInTree(c.getChildren(), id);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());