[CMS-VISUAL] D1-D10 后端: 新组件数据槽(banner 扩 topic/event 位 5 列 + cms_person 新表+CRUD+防跨站)+ 6 区块类型校验与装配(空数据整块不渲染 R-44)+ 骨架新组件 fragment/内置 22 图标集/共享动效 JS(reveal+count-up R-42)+ searchStyle 搜索浮层(D-10,含坑④条件包裹修复)+ 三保留主题 4 新参数

This commit is contained in:
chenxing
2026-09-28 14:04:19 +08:00
parent 089cb54011
commit 4cb435b715
23 changed files with 1673 additions and 52 deletions
@@ -0,0 +1,43 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.service.impl.PersonService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
/**
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
*/
@RestController
@RequestMapping("/api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping("/list")
public List<PersonVo> list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) {
return personService.list(authUserWithDept, siteId);
}
@PostMapping("/save")
public PersonVo save(BoaUser authUserWithDept, @Valid @RequestBody PersonSaveRequest request) {
return personService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
personService.delete(authUserWithDept, request);
}
}
@@ -42,6 +42,8 @@ public class PortalPageRenderer {
private final CmsArticleRepository articleRepository;
private final ThemeRegistry themeRegistry;
/** docs/20 D-7:people 区块数据源 */
private final com.yunhai.cms.repository.CmsPersonRepository personRepository;
/**
* M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html
@@ -348,13 +350,27 @@ public class PortalPageRenderer {
return buildQuickLinksBlock(spec);
case "notice_bar":
return buildNoticeBarBlock(spec);
// ===== docs/20 D-1~D-9 新组件(空数据返回 null = 整块不渲染,R-44)=====
case "stats_band":
return buildStatsBandBlock(spec);
case "featured_topics":
return buildFeaturedTopicsBlock(site, spec, url);
case "video_tour":
return buildVideoTourBlock(spec, url);
case "events":
return buildEventsBlock(site, spec, url);
case "people":
return buildPeopleBlock(site, spec, treeIds);
case "badges":
return buildBadgesBlock(spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题)。
* F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。 */
* F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。
* D-4(docs/20):subtitle 全类型通用(中英双语栏目标题——主题 titleStyle 参数控制展示开关)。 */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
@@ -366,6 +382,10 @@ public class PortalPageRenderer {
title = fallbackTitle;
}
block.put("title", title);
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
@@ -454,7 +474,11 @@ public class PortalPageRenderer {
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
block.put("subtitle", subtitle);
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
// D-8(docs/20):campus_wall 布局开关(grid 规整网格 / masonry 瀑布流)
block.put("layout", "masonry".equals(str(spec.get("layout"), "")) ? "masonry" : "grid");
block.put("items", activeBanners(site, "gallery", url));
return block;
}
@@ -475,8 +499,8 @@ public class PortalPageRenderer {
}
/**
* 快捷入口宫格(v2 手册 §2.8 类型 A):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
* 快捷入口宫格(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url/icon 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
*/
private Map<String, Object> buildQuickLinksBlock(Map<String, Object> spec) {
List<Map<String, Object>> links = new ArrayList<>();
@@ -495,6 +519,11 @@ public class PortalPageRenderer {
Map<String, Object> link = new HashMap<>();
link.put("name", name);
link.put("url", linkUrl);
// D-2:可选图标标识符(未选时骨架渲染首字徽章兜底)
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
link.put("icon", icon);
}
links.add(link);
}
}
@@ -516,8 +545,214 @@ public class PortalPageRenderer {
return block;
}
// ===== docs/20 D 新组件装配(空数据返回 null → buildLayoutBlocks 跳过 = 整块不渲染,R-44)=====
/** stats_band(D-1 数字计数带):stats 内联数组透传(骨架共享 count-up 动画);空/无有效项 → 不渲染 */
private Map<String, Object> buildStatsBandBlock(Map<String, Object> spec) {
List<Map<String, Object>> stats = new ArrayList<>();
Object raw = spec.get("stats");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String label = str(item.get("label"), "").trim();
String value = str(item.get("value"), "").trim();
if (label.isEmpty() || value.isEmpty()) {
continue;
}
Map<String, Object> stat = new HashMap<>();
stat.put("label", label);
stat.put("value", value);
String unit = str(item.get("unit"), "").trim();
if (!unit.isEmpty()) {
stat.put("unit", unit);
}
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
stat.put("icon", icon);
}
stats.add(stat);
}
}
if (stats.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "stats_band", "数说学校");
block.put("stats", stats);
return block;
}
/** featured_topics(D-3 专题大卡):数据 = banner 表 topic 展示位(描述/CTA 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildFeaturedTopicsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if (!"topic".equals(b.getPosition()) || !isActiveNow(b)) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("imageUrl", url.upload(b.getImageUrl()));
item.put("linkUrl", resolveBannerLink(b, url));
if (hasText(b.getDescription())) {
item.put("description", b.getDescription());
}
if (hasText(b.getCtaText())) {
item.put("ctaText", b.getCtaText());
}
if (hasText(b.getCtaUrl())) {
item.put("ctaUrl", b.getCtaUrl());
}
items.add(item);
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "featured_topics", "专题推荐");
block.put("items", items);
return block;
}
/** video_tour(D-5 视频/VR 校园):cover 资产补基准 + url + title;脏数据(缺 cover/url)→ 不渲染 */
private Map<String, Object> buildVideoTourBlock(Map<String, Object> spec, PortalUrlHelper url) {
String cover = str(spec.get("cover"), "").trim();
String videoUrl = str(spec.get("url"), "").trim();
if (!ASSET_URL_PATTERN.matcher(cover).matches() || !LAYOUT_LINK_URL.matcher(videoUrl).matches()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "video_tour", "云端校园");
block.put("cover", url.upload(cover));
block.put("videoUrl", videoUrl);
return block;
}
/** events(D-6 学术日历/活动):数据 = banner 表 event 展示位(event_date/location 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildEventsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<PortalBanner> events = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if ("event".equals(b.getPosition()) && isActiveNow(b)) {
events.add(b);
}
}
// 活动日历语义:按活动日期升序(无日期的排后按 sortNo),最多 6 条
events.sort(java.util.Comparator
.comparing((PortalBanner b) -> b.getEventDate() == null ? java.time.LocalDateTime.MAX : b.getEventDate())
.thenComparing(PortalBanner::getSortNo, java.util.Comparator.nullsLast(Integer::compareTo)));
if (events.size() > 6) {
events = events.subList(0, 6);
}
if (events.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : events) {
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("linkUrl", resolveBannerLink(b, url));
if (b.getEventDate() != null) {
item.put("eventDate", b.getEventDate());
}
if (hasText(b.getLocation())) {
item.put("location", b.getLocation());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "events", "学术活动");
block.put("items", items);
return block;
}
/** people(D-7 名师风采):cms_person 启用数据(categoryId 可选过滤,须在本站栏目树内);无数据 → 不渲染 */
private Map<String, Object> buildPeopleBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
Integer categoryId = null;
Object rawCategory = spec.get("categoryId");
if (rawCategory instanceof Number && ((Number) rawCategory).intValue() > 0
&& treeIds.contains(((Number) rawCategory).intValue())) {
categoryId = ((Number) rawCategory).intValue();
}
List<com.yunhai.cms.entity.CmsPerson> people = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p
: personRepository.findBySiteIdAndStatusOrderBySortNoAscIdAsc(site.getId(), 1)) {
if (categoryId != null && !categoryId.equals(p.getCategoryId())) {
continue;
}
people.add(p);
if (people.size() >= 12) {
break;
}
}
if (people.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p : people) {
Map<String, Object> item = new HashMap<>();
item.put("name", p.getName());
if (hasText(p.getTitle())) {
item.put("title", p.getTitle());
}
if (hasText(p.getBio())) {
item.put("bio", p.getBio());
}
if (hasText(p.getAvatar())) {
item.put("avatar", p.getAvatar());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "people", "名师风采");
block.put("items", items);
return block;
}
/** badges(D-9 排名/学科评估徽章带):badgeItems 内联数组透传;空 → 不渲染 */
private Map<String, Object> buildBadgesBlock(Map<String, Object> spec) {
List<Map<String, Object>> items = new ArrayList<>();
Object raw = spec.get("badgeItems");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> m = (Map<?, ?>) o;
String label = str(m.get("label"), "").trim();
String image = str(m.get("image"), "").trim();
if (label.isEmpty() || !ASSET_URL_PATTERN.matcher(image).matches()) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("label", label);
item.put("image", image);
String desc = str(m.get("desc"), "").trim();
if (!desc.isEmpty()) {
item.put("desc", desc);
}
items.add(item);
}
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "badges", "实力声誉");
block.put("items", items);
return block;
}
/** banner 时间窗过滤(activeBanners 同语义,供 topic/event 位复用;快照内 banner 已过滤启用态) */
private boolean isActiveNow(PortalBanner b) {
java.time.LocalDateTime now = java.time.LocalDateTime.now();
return (b.getStartTime() == null || !now.isBefore(b.getStartTime()))
&& (b.getEndTime() == null || now.isBefore(b.getEndTime()));
}
// ===== 布局装配工具 =====
/** 区块内图片资产 URL 形态(与 SiteService ASSET_PATTERN 同语义) */
private static final Pattern ASSET_URL_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$");
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}