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
build / build-push (push) Successful in 52s
This commit is contained in:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user