feat(portal): 门户左侧栏目导航+首页视觉升级+主题正名批次-PortalSideNav模型+attachCategoryNavigation注入doCategory/doArticle三件套categoryPath/navTopId/sideNav(站点快照树推导零新增查询,home/search不注入);7主题侧栏两栏markup+窄屏横滚+顶导航祖先高亮+二级下拉当前项高亮+面包屑全路径;cms_category.home_style全链路(news_grid 16条8x2日期前缀/media_focus形态)+PortalListData.Item补source来源徽标四处装配;Banner机制gallery展示位→galleryBanners四列大图轮播(自由字符串零DDL);页脚config 12新键(footer_addresses多校区/qr_*/social_*含URL校验,footerAddressLines由PortalSiteSnapshot.of拆行派生);新增展示型皮肤包campus-glow;fresh-mint2正名fresh-mint(theme.json/头注释同步);AttachmentService scene白名单加category(单页富文本贴图免清孤儿);新增PortalSideNavIT 8+PortalHomeBlocksIT 3,CategoryServiceIT/SiteServiceIT/PortalSsrIT/ThemeContractIT等单类独跑全绿;附sql/clear_cms_data.sql全量清库脚本(清cms_*六表留平台账号/主题包/上传文件,测试数据重走用)
build / build-push (push) Successful in 1m47s

This commit is contained in:
chenxing
2026-09-10 11:10:48 +08:00
parent f4b1749377
commit 98d640faba
77 changed files with 2564 additions and 175 deletions
@@ -26,7 +26,7 @@ public class AttachmentController {
private final AttachmentService attachmentService;
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
@PostMapping("/upload")
public AttachmentVo upload(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@@ -133,6 +133,8 @@ public class PortalController {
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
model.addAttribute("category", category);
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
pageRenderer.attachCategoryNavigation(site, categoryId, model);
model.addAttribute("pageTitle", seoTitle(category, site));
model.addAttribute("seoDescription", category.getSeoDescription());
model.addAttribute("canonical", url.category(categoryId));
@@ -198,6 +200,8 @@ public class PortalController {
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
model.addAttribute("article", detail.withContent(
@@ -356,6 +360,7 @@ public class PortalController {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -5,6 +5,7 @@ import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSideNav;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.utils.PortalUrlHelper;
@@ -28,8 +29,9 @@ import java.util.Map;
@RequiredArgsConstructor
public class PortalPageRenderer {
/** 首页每个一级栏目取 N 条 */
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
private static final int HOME_BLOCK_SIZE = 8;
private static final int HOME_BLOCK_GRID_SIZE = 16;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
@@ -46,6 +48,7 @@ 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));
@@ -63,6 +66,71 @@ public class PortalPageRenderer {
// ===== 内部装配(自 PortalController 原样迁移)=====
/**
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
*/
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
if (currentCategoryId == null) {
return;
}
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
model.addAttribute("categoryPath", path);
if (path.isEmpty()) {
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
}
PortalCategory top = path.get(0);
model.addAttribute("navTopId", top.getId());
if (!top.getChildren().isEmpty()) {
PortalSideNav nav = new PortalSideNav();
nav.setTop(top);
nav.setItems(top.getChildren());
nav.setActiveId(resolveActiveId(path, currentCategoryId));
model.addAttribute("sideNav", nav);
}
}
/**
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
*/
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
if (path.size() == 2) {
return currentCategoryId;
}
return path.size() > 2 ? path.get(1).getId() : null;
}
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
for (PortalCategory root : roots) {
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return new ArrayList<>();
}
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
List<PortalCategory> path = new ArrayList<>();
path.add(node);
return path;
}
for (PortalCategory child : node.getChildren()) {
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
if (sub != null) {
sub.add(0, node);
return sub;
}
}
return null;
}
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
PortalUrlHelper url) {
@@ -137,6 +205,7 @@ public class PortalPageRenderer {
/**
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
*/
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
PortalUrlHelper url) {
@@ -145,12 +214,14 @@ public class PortalPageRenderer {
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
continue;
}
int blockSize = "news_grid".equals(category.getHomeStyle())
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
List<Integer> categoryIds = new ArrayList<>();
collectListDescendantIds(category, categoryIds);
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_BLOCK_SIZE,
PageRequest.of(0, blockSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
@@ -159,6 +230,7 @@ public class PortalPageRenderer {
}
Map<String, Object> block = new HashMap<>();
block.put("category", category);
block.put("homeStyle", category.getHomeStyle());
block.put("items", items);
block.put("moreUrl", url.category(category.getId()));
blocks.add(block);
@@ -182,6 +254,7 @@ public class PortalPageRenderer {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;