[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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user