Compare commits

..
34 changed files with 2105 additions and 62 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,20 +350,42 @@ 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 取标题) */
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题)。
* 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);
if (spec.get("id") != null) {
block.put("id", String.valueOf(spec.get("id")));
}
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
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);
@@ -450,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;
}
@@ -471,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<>();
@@ -491,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);
}
}
@@ -512,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);
}
@@ -59,4 +59,27 @@ public class CmsBanner extends BaseEntity {
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
// ===== docs/20 D-3/D-6:banner 表扩展(topic=专题大卡位 / event=学术活动位)=====
// 仅对应展示位消费;home/gallery 位忽略。可空列,ddl-auto=update 自动补列(SQL 脚本另附 resources/sql)。
/** 专题大卡描述文案(position=topic) */
@Column(name = "description", length = 500)
private String description;
/** 专题大卡 CTA 按钮文字(position=topic,如「进入专题」) */
@Column(name = "cta_text", length = 100)
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Column(name = "cta_url", length = 500)
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
@Column(name = "event_date")
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Column(name = "location", length = 200)
private String location;
}
@@ -0,0 +1,56 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 名师风采人物(docs/20 D-7:首页 people 区块数据源,新表)。
* 人物按站点隔离、可挂栏目分组(category_id 可空 = 未分组);头像走附件上传通道。
* DDL:hibernate ddl-auto=update 自动建表;DBA 审阅脚本见 resources/sql/cms_person.sql(不自动执行)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_person", indexes = {
@Index(name = "idx_person_site", columnList = "site_id, status, sort_no"),
@Index(name = "idx_person_category", columnList = "category_id")
})
public class CmsPerson extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 姓名 */
@Column(name = "name", nullable = false, length = 100)
private String name;
/** 头衔/职称(如 教授、长江学者) */
@Column(name = "title", length = 100)
private String title;
/** 一句话简介 */
@Column(name = "bio", length = 500)
private String bio;
/** 头像 URL(附件上传,/cms/upload/** 或 http(s)) */
@Column(name = "avatar", length = 500)
private String avatar;
/** 所属栏目外键(可空 = 未分组;people 区块按此过滤) */
@Column(name = "category_id")
private Integer categoryId;
/** 同级排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -8,7 +8,7 @@ import javax.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 轮播保存(校验 link_type/link_value,方案 §9.2)。
* 轮播保存(校验 link_type/link_value,方案 §9.2;docs/20 D-3/D-6 扩展 topic/event 位字段)。
*/
@Data
public class BannerSaveRequest {
@@ -49,4 +49,25 @@ public class BannerSaveRequest {
/** 0=停用 1=启用 */
private Integer status = 1;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展(仅对应位消费,其余位忽略)=====
/** 专题大卡描述(position=topic) */
@Size(max = 500, message = "专题描述过长")
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
@Size(max = 100, message = "按钮文字过长")
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Size(max = 500, message = "按钮地址过长")
private String ctaUrl;
/** 活动日期(position=event) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Size(max = 200, message = "活动地点过长")
private String location;
}
@@ -0,0 +1,42 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 名师风采人物保存(docs/20 D-7)。
*/
@Data
public class PersonSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
@NotBlank(message = "姓名不得为空")
@Size(max = 100, message = "姓名过长")
private String name;
@Size(max = 100, message = "头衔过长")
private String title;
@Size(max = 500, message = "简介过长")
private String bio;
/** 头像(/cms/upload/** 或 http(s);可空 = 首字徽章兜底) */
@Size(max = 500)
private String avatar;
/** 所属栏目(可空 = 未分组;须属于本站点) */
private Integer categoryId;
private Integer sortNo = 0;
/** 0=停用 1=启用 */
private Integer status = 1;
}
@@ -34,6 +34,22 @@ public class PortalBanner {
/** 下线时间(渲染方过滤) */
private LocalDateTime endTime;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
/** 专题大卡描述(position=topic) */
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
private String location;
public static PortalBanner of(CmsBanner b) {
PortalBanner item = new PortalBanner();
item.setId(b.getId());
@@ -45,6 +61,11 @@ public class PortalBanner {
item.setSortNo(b.getSortNo());
item.setStartTime(b.getStartTime());
item.setEndTime(b.getEndTime());
item.setDescription(b.getDescription());
item.setCtaText(b.getCtaText());
item.setCtaUrl(b.getCtaUrl());
item.setEventDate(b.getEventDate());
item.setLocation(b.getLocation());
return item;
}
}
@@ -34,6 +34,13 @@ public class BannerVo {
private Integer status;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
private String description;
private String ctaText;
private String ctaUrl;
private java.time.LocalDateTime eventDate;
private String location;
public static BannerVo of(CmsBanner b) {
BannerVo vo = new BannerVo();
vo.setId(b.getId());
@@ -47,6 +54,11 @@ public class BannerVo {
vo.setStartTime(b.getStartTime());
vo.setEndTime(b.getEndTime());
vo.setStatus(b.getStatus());
vo.setDescription(b.getDescription());
vo.setCtaText(b.getCtaText());
vo.setCtaUrl(b.getCtaUrl());
vo.setEventDate(b.getEventDate());
vo.setLocation(b.getLocation());
return vo;
}
}
@@ -0,0 +1,39 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsPerson;
import lombok.Data;
import java.time.LocalDateTime;
/** 名师风采人物(docs/20 D-7) */
@Data
public class PersonVo {
private Integer id;
private Integer siteId;
private String name;
private String title;
private String bio;
private String avatar;
private Integer categoryId;
private Integer sortNo;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public static PersonVo of(CmsPerson p) {
PersonVo vo = new PersonVo();
vo.setId(p.getId());
vo.setSiteId(p.getSiteId());
vo.setName(p.getName());
vo.setTitle(p.getTitle());
vo.setBio(p.getBio());
vo.setAvatar(p.getAvatar());
vo.setCategoryId(p.getCategoryId());
vo.setSortNo(p.getSortNo());
vo.setStatus(p.getStatus());
vo.setCreateTime(p.getCreateTime());
vo.setUpdateTime(p.getUpdateTime());
return vo;
}
}
@@ -0,0 +1,16 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsPerson;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/** 名师风采(docs/20 D-7) */
public interface CmsPersonRepository extends JpaRepository<CmsPerson, Integer> {
/** 管理端:站点全量(含停用,按排序) */
List<CmsPerson> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
/** 门户:启用人物(可选栏目过滤),按排序 */
List<CmsPerson> findBySiteIdAndStatusOrderBySortNoAscIdAsc(Integer siteId, Integer status);
}
@@ -68,11 +68,11 @@ public class AttachmentService {
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图。svc_item/svc_item_asset 两个事项场景已随事项库迁出办事大厅
* (V2.0,见 docs/19 §九),CMS 侧下线。
* category=单页栏目贴图 / person=名师头像(docs/20 D-7)。svc_item/svc_item_asset 两个事项场景
* 已随事项库迁出办事大厅(V2.0,见 docs/19 §九),CMS 侧下线。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category");
Arrays.asList("banner", "link", "site", "category", "person");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -81,6 +81,12 @@ public class BannerService {
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
}
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
// docs/20 D-3/D-6:topic/event 位扩展字段透传(其余位一并落库,渲染端按位消费)
banner.setDescription(req.getDescription());
banner.setCtaText(req.getCtaText());
banner.setCtaUrl(req.getCtaUrl());
banner.setEventDate(req.getEventDate());
banner.setLocation(req.getLocation());
banner = bannerRepository.save(banner);
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
attachmentService.release("banner", banner.getId());
@@ -80,8 +80,7 @@ public class CategoryService {
if (StringUtils.hasText(req.getHomeStyle()) && !HOME_STYLE_VALUES.contains(req.getHomeStyle())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"首页展示形态非法:" + req.getHomeStyle() + "(仅支持 news_grid / media_focus)");
}
CmsCategory category;
} CmsCategory category;
if (req.getId() == null) {
category = new CmsCategory();
category.setSiteId(site.getId());
@@ -98,7 +97,9 @@ public class CategoryService {
category.setType(type.getCode());
category.setLinkUrl(req.getLinkUrl());
category.setTemplate(req.getTemplate());
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : null);
// F3(docs/20 §四):「首页展示形态」唯一入口收敛到首页布局编辑器,栏目管理不再编辑该字段——
// 入参缺省时保留已有值(存量数据不清除,无布局站点的回落渲染仍消费);显式传值仍校验(API 兼容)
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : category.getHomeStyle());
category.setContent(HtmlSanitizer.clean(req.getContent()));
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
category.setSeoTitle(req.getSeoTitle());
@@ -0,0 +1,94 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsPerson;
import com.yunhai.cms.event.SiteContentChangedEvent;
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.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsPersonRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
/**
* 名师风采人物管理(docs/20 D-7):站点级 CRUD + people 区块数据源。
* 头像附件引用走 scene=person(B2 模型);categoryId 须属于本站点(防跨站注入)。
*/
@Service
@RequiredArgsConstructor
public class PersonService {
private final CmsPersonRepository personRepository;
private final CmsCategoryRepository categoryRepository;
private final SiteService siteService;
private final AttachmentService attachmentService;
private final ApplicationEventPublisher eventPublisher;
public List<PersonVo> list(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
return personRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId).stream()
.map(PersonVo::of)
.collect(Collectors.toList());
}
@Transactional
public PersonVo save(BoaUser user, PersonSaveRequest req) {
siteService.requireManageable(user, req.getSiteId());
if (req.getCategoryId() != null) {
CmsCategory category = categoryRepository.findById(req.getCategoryId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在"));
if (!category.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "所属栏目不属于本站点");
}
}
CmsPerson person;
if (req.getId() == null) {
person = new CmsPerson();
person.setSiteId(req.getSiteId());
} else {
person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该人物");
}
}
person.setName(req.getName());
person.setTitle(req.getTitle());
person.setBio(req.getBio());
person.setAvatar(req.getAvatar());
person.setCategoryId(req.getCategoryId());
person.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
person.setStatus(req.getStatus() == null ? 1 : req.getStatus());
person = personRepository.save(person);
// B2 附件引用:先解引用再回写新头像(编辑换图时旧图回未使用);幂等
attachmentService.release("person", person.getId());
if (StringUtils.hasText(req.getAvatar())) {
attachmentService.markUsed(req.getSiteId(), req.getAvatar(), "person", person.getId());
}
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
return PersonVo.of(person);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsPerson person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
siteService.requireManageable(user, req.getSiteId());
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该人物");
}
personRepository.delete(person);
attachmentService.release("person", person.getId());
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
}
}
@@ -60,10 +60,12 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** home_layout(docs/16 §4.6 + v2 手册 §2.8)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
/** home_layout(docs/16 §4.6 + v2 手册 §2.8 + docs/20 D 组件库扩展)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar")));
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
// docs/20 D-1~D-9 新组件
"stats_band", "featured_topics", "video_tour", "events", "people", "badges")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30;
private static final int HOME_LAYOUT_MAX_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100;
@@ -79,6 +81,18 @@ public class SiteService {
/** notice_bar 提示级别白名单(缺省 info) */
private static final Set<String> HOME_LAYOUT_NOTICE_LEVELS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("info", "warn")));
// ===== docs/20 D 组件库新增约束 =====
/** stats_band.stats 项数上限 */
private static final int HOME_LAYOUT_MAX_STATS = 8;
/** featured_topics.items 项数上限 */
private static final int HOME_LAYOUT_MAX_TOPICS = 6;
/** badges.items 项数上限 */
private static final int HOME_LAYOUT_MAX_BADGES = 8;
/** 区块内图标标识符(D-2 内置图标集 key) */
private static final Pattern HOME_LAYOUT_ICON_PATTERN = Pattern.compile("^[a-z0-9-]{1,30}$");
/** gallery 布局形态(D-8 campus_wall) */
private static final Set<String> HOME_LAYOUT_GALLERY_LAYOUTS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("grid", "masonry")));
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
@@ -405,6 +419,20 @@ public class SiteService {
validateQuickLinksBlock(block);
} else if ("notice_bar".equals(type.asText())) {
validateNoticeBarBlock(block);
} else if ("stats_band".equals(type.asText())) {
validateStatsBandBlock(block);
} else if ("featured_topics".equals(type.asText())) {
validateFeaturedTopicsBlock(block);
} else if ("video_tour".equals(type.asText())) {
validateVideoTourBlock(block);
} else if ("badges".equals(type.asText())) {
validateBadgesBlock(block);
} else if ("gallery".equals(type.asText())) {
JsonNode layout = block.get("layout");
if (layout != null && (!layout.isTextual()
|| !HOME_LAYOUT_GALLERY_LAYOUTS.contains(layout.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "gallery layout 只能是 grid 或 masonry");
}
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
@@ -429,6 +457,14 @@ public class SiteService {
referencedIds.add(id.intValue());
}
}
// people 区块(docs/20 D-7):categoryId 单值,同走跨站注入防护
JsonNode categoryId = block.get("categoryId");
if (categoryId != null) {
if (!categoryId.isNumber() || categoryId.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryId 必须为正整数");
}
referencedIds.add(categoryId.intValue());
}
}
if (!referencedIds.isEmpty()) {
if (siteId == null) {
@@ -447,7 +483,7 @@ public class SiteService {
}
}
/** quick_links(v2 手册 §2.8 类型 A):items 必填、1~12 项,每项 {name ≤20 字, url 合法} */
/** quick_links(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):items 必填、1~12 项,每项 {name ≤20 字, url 合法, icon 可选标识符} */
private void validateQuickLinksBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null) {
@@ -474,6 +510,12 @@ public class SiteService {
"quick_links 项 name 须为不超过 " + HOME_LAYOUT_MAX_LINK_NAME + " 字的文本");
}
requireLayoutLink(item.get("url"), "quick_links 项");
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 icon 须为内置图标标识符(小写字母/数字/短横线,如 zhaosheng)");
}
}
}
@@ -496,7 +538,7 @@ public class SiteService {
}
}
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url) */
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url / featured_topics ctaUrl / video_tour url) */
private void requireLayoutLink(JsonNode url, String what) {
if (url == null || !url.isTextual() || !HOME_LAYOUT_LINK_PATTERN.matcher(url.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
@@ -504,6 +546,118 @@ public class SiteService {
}
}
/** 区块内图片资产校验(站点上传路径 / 开头或 http(s) 地址) */
private void requireLayoutAsset(JsonNode asset, String what) {
if (asset == null || !asset.isTextual() || !ASSET_PATTERN.matcher(asset.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " 须为站点上传路径(/ 开头)或 http(s) 地址");
}
}
/** stats_band(docs/20 D-1):stats 数组 1~8 项,每项 {label ≤20, value ≤10, unit ≤10, icon 可选} */
private void validateStatsBandBlock(JsonNode block) {
JsonNode stats = block.get("stats");
if (stats == null || !stats.isArray() || stats.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 区块必须填写 stats(统计项数组)");
}
if (stats.size() > HOME_LAYOUT_MAX_STATS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"stats_band stats 不能超过 " + HOME_LAYOUT_MAX_STATS + " 项");
}
for (JsonNode item : stats) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band stats 每项必须是对象({label, value, unit})");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 label 须为不超过 20 字的文本");
}
JsonNode value = item.get("value");
if (value == null || !value.isTextual() || value.asText().trim().isEmpty()
|| value.asText().trim().length() > 10) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 value 须为不超过 10 字的数字文本(如 3.2 万)");
}
JsonNode unit = item.get("unit");
if (unit != null && (!unit.isTextual() || unit.asText().length() > 10)) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 unit 须为不超过 10 字的文本");
}
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 icon 须为内置图标标识符");
}
}
}
/** featured_topics(docs/20 D-3):items 1~6 项,每项 {title ≤50, description ≤200, image 资产, ctaText ≤20, ctaUrl 链接} */
private void validateFeaturedTopicsBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 区块必须填写 items(专题卡数组)");
}
if (items.size() > HOME_LAYOUT_MAX_TOPICS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"featured_topics items 不能超过 " + HOME_LAYOUT_MAX_TOPICS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics items 每项必须是对象");
}
JsonNode title = item.get("title");
if (title == null || !title.isTextual() || title.asText().trim().isEmpty()
|| title.asText().trim().length() > 50) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 title 须为不超过 50 字的文本");
}
JsonNode description = item.get("description");
if (description != null && (!description.isTextual() || description.asText().length() > 200)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 description 不能超过 200 字");
}
requireLayoutAsset(item.get("image"), "featured_topics 项 image");
JsonNode ctaText = item.get("ctaText");
if (ctaText != null && (!ctaText.isTextual() || ctaText.asText().length() > 20)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 ctaText 不能超过 20 字");
}
if (item.has("ctaUrl")) {
requireLayoutLink(item.get("ctaUrl"), "featured_topics 项");
}
}
}
/** video_tour(docs/20 D-5):cover 资产 + url 链接 + title ≤100 */
private void validateVideoTourBlock(JsonNode block) {
requireLayoutAsset(block.get("cover"), "video_tour cover");
requireLayoutLink(block.get("url"), "video_tour");
}
/** badges(docs/20 D-9):badgeItems 1~8 项,每项 {label ≤20, desc ≤50, image 资产}
* (键名与前端 cleanBlock 序列化一致——避免与 quick_links/featured_topics 的 items 语义混淆) */
private void validateBadgesBlock(JsonNode block) {
JsonNode items = block.get("badgeItems");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 区块必须填写 items(徽章数组)");
}
if (items.size() > HOME_LAYOUT_MAX_BADGES) {
throw new BizException(ErrorCode.PARAM_ERROR,
"badges items 不能超过 " + HOME_LAYOUT_MAX_BADGES + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges items 每项必须是对象");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 label 须为不超过 20 字的文本");
}
JsonNode desc = item.get("desc");
if (desc != null && (!desc.isTextual() || desc.asText().length() > 50)) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 desc 不能超过 50 字");
}
requireLayoutAsset(item.get("image"), "badges 项 image");
}
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
@@ -0,0 +1,31 @@
-- ============================================================
-- docs/20 D-3/D-6/D-7:首页新组件库数据结构变更(DDL 供 DBA 审阅,不自动执行)
-- 日期:2026-09-28
-- 说明:应用侧由 JPA ddl-auto=update 自动建列/建表;本脚本为生产发布前
-- 人工变更单底稿。执行前请备份相关表。
-- ============================================================
-- ① cms_banner 扩展列(D-3 专题大卡位 topic / D-6 学术活动位 event;均可空,老数据不受影响)
ALTER TABLE cms_banner
ADD COLUMN description VARCHAR(500) NULL COMMENT '专题大卡描述(position=topic)' AFTER status,
ADD COLUMN cta_text VARCHAR(100) NULL COMMENT '专题大卡 CTA 按钮文字(position=topic)' AFTER description,
ADD COLUMN cta_url VARCHAR(500) NULL COMMENT '专题大卡 CTA 跳转地址(position=topic)' AFTER cta_text,
ADD COLUMN event_date DATETIME NULL COMMENT '活动日期(position=event)' AFTER cta_url,
ADD COLUMN location VARCHAR(200) NULL COMMENT '活动地点(position=event)' AFTER event_date;
-- ② cms_person 新表(D-7 名师风采,people 区块数据源)
CREATE TABLE cms_person (
id INT AUTO_INCREMENT PRIMARY KEY,
site_id INT NOT NULL COMMENT '所属站点(cms_site.id)',
name VARCHAR(100) NOT NULL COMMENT '姓名',
title VARCHAR(100) NULL COMMENT '头衔/职称',
bio VARCHAR(500) NULL COMMENT '一句话简介',
avatar VARCHAR(500) NULL COMMENT '头像 URL(/cms/upload/** 或 http(s),空=首字徽章兜底)',
category_id INT NULL COMMENT '所属栏目外键(cms_category.id,空=未分组)',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级排序(升序)',
status INT NOT NULL DEFAULT 1 COMMENT '0=停用 1=启用',
create_time DATETIME NULL,
update_time DATETIME NULL,
KEY idx_person_site (site_id, status, sort_no),
KEY idx_person_category (category_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '名师风采人物(docs/20 D-7)';
@@ -31,10 +31,19 @@
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="cg-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="cg-site-search" th:action="${url.search('', null)}" method="get" role="search">
<!-- D-10(docs/20):searchStyle=overlay 换全屏搜索浮层;none 隐藏;inline/缺省保留原表单。
坑④:th:replace 优先级(100)先于 th:if(300)——条件必须放外层 th:block,replace 放子元素 -->
<th:block th:if="${themeParams != null and themeParams['searchStyle'] == 'overlay'}">
<th:block th:replace="~{'themes/common/fragments/search' :: overlay(${url})}" />
</th:block>
<th:block th:if="${themeParams == null or themeParams['searchStyle'] == null or themeParams['searchStyle'] == 'inline'}">
<form class="cg-site-search"
th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</th:block>
</div>
<nav class="cg-nav" aria-label="栏目导航">
@@ -30,7 +30,9 @@
注意坑④:th:replace(优先级 100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素。 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: previewScript}" />
<th:block th:each="block : ${layoutBlocks}">
<div class="plb-anchor" th:attr="data-plb-id=${block['id']}" style="display:contents">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
@@ -48,6 +50,7 @@
<th:block th:case="'notice_bar'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: notice_bar(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</div>
</th:block>
</th:block>
@@ -8,19 +8,152 @@
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--cg-primary", "label": "主色", "type": "color", "default": "#0e8a5f" },
{ "key": "primaryDark", "cssVar": "--cg-primary-dark", "label": "主色加深", "type": "color", "default": "#0a6244" },
{ "key": "accent", "cssVar": "--cg-accent", "label": "强调色", "type": "color", "default": "#ff7a1a" },
{ "key": "text", "cssVar": "--cg-text", "label": "正文文字色", "type": "color", "default": "#2b2f33" },
{ "key": "textLight", "cssVar": "--cg-text-light", "label": "次要文字色", "type": "color", "default": "#7a8288" },
{ "key": "bg", "cssVar": "--cg-bg", "label": "页面底色", "type": "color", "default": "#f4f7f5" },
{ "key": "cardBg", "cssVar": "--cg-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--cg-border", "label": "边框色", "type": "color", "default": "#e3e8e4" },
{ "key": "galleryCols", "label": "展厅每行列数", "type": "select", "default": "4",
{
"key": "primary",
"cssVar": "--cg-primary",
"label": "主色",
"type": "color",
"default": "#0e8a5f"
},
{
"key": "primaryDark",
"cssVar": "--cg-primary-dark",
"label": "主色加深",
"type": "color",
"default": "#0a6244"
},
{
"key": "accent",
"cssVar": "--cg-accent",
"label": "强调色",
"type": "color",
"default": "#ff7a1a"
},
{
"key": "text",
"cssVar": "--cg-text",
"label": "正文文字色",
"type": "color",
"default": "#2b2f33"
},
{
"key": "textLight",
"cssVar": "--cg-text-light",
"label": "次要文字色",
"type": "color",
"default": "#7a8288"
},
{
"key": "bg",
"cssVar": "--cg-bg",
"label": "页面底色",
"type": "color",
"default": "#f4f7f5"
},
{
"key": "cardBg",
"cssVar": "--cg-card-bg",
"label": "卡片底色",
"type": "color",
"default": "#ffffff"
},
{
"key": "border",
"cssVar": "--cg-border",
"label": "边框色",
"type": "color",
"default": "#e3e8e4"
},
{
"key": "galleryCols",
"label": "展厅每行列数",
"type": "select",
"default": "4",
"options": [
{ "value": "3", "label": "3 列" },
{ "value": "4", "label": "4 列" },
{ "value": "5", "label": "5 列" }
{
"value": "3",
"label": "3 列"
},
{
"value": "4",
"label": "4 列"
},
{
"value": "5",
"label": "5 列"
}
]
},
{
"key": "animation",
"label": "首页动效",
"type": "select",
"default": "on",
"options": [
{
"value": "on",
"label": "开启(滚动入场 + 数字计数)"
},
{
"value": "off",
"label": "关闭(静态呈现)"
}
]
},
{
"key": "titleStyle",
"label": "栏目标题语言",
"type": "select",
"default": "cn",
"options": [
{
"value": "cn",
"label": "仅中文"
},
{
"value": "bilingual",
"label": "中英双语(大中文 + 斜体英文副题)"
}
]
},
{
"key": "iconShape",
"label": "图标形状语言",
"type": "select",
"default": "circle",
"options": [
{
"value": "circle",
"label": "圆形徽章"
},
{
"value": "hexagon",
"label": "六边形"
},
{
"value": "square",
"label": "圆角方形"
}
]
},
{
"key": "searchStyle",
"label": "门户搜索样式",
"type": "select",
"default": "inline",
"options": [
{
"value": "none",
"label": "不显示搜索"
},
{
"value": "inline",
"label": "通栏搜索条(banner 下方白色大搜索条)"
},
{
"value": "overlay",
"label": "全屏搜索浮层(右上放大镜唤起)"
}
]
}
]
@@ -83,11 +83,95 @@
.plb-notice-warn{background:var(--plb-warn-bg);border-color:var(--plb-warn-border)}
.plb-notice-warn .plb-notice-tag{color:var(--plb-warn-fg)}
/* 全宽区块必须跨满整行:网格型主题(mint-breeze 系 .home-main 是三列)会把它们塞进单列,
非网格父容器下 grid-column 无副作用,故由骨架自带。 */
非网格父容器下 grid-column 无副作用,故由骨架自带。
F2(docs/20):布局区块经 .plb-anchor(display:contents 预览锚点)包裹时按子选择器等效覆盖。 */
main > .plb-ticker{grid-column:1 / -1}
main > .plb-newshot{grid-column:1 / -1}
main > .plb-section{grid-column:1 / -1}
main > .plb-notice-bar{grid-column:1 / -1}
main > .plb-anchor{display:contents;grid-column:auto}
main > .plb-anchor > .plb-ticker{grid-column:1 / -1}
main > .plb-anchor > .plb-newshot{grid-column:1 / -1}
main > .plb-anchor > .plb-section{grid-column:1 / -1}
main > .plb-anchor > .plb-notice-bar{grid-column:1 / -1}
/* ===== docs/20 D 组件库骨架样式(跟随主题变量;campus-glow 等可自持精细版覆盖) ===== */
/* D-4 双语标题:themeParams.titleStyle=bilingual 时副题斜体展示(默认 cn 不展示由渲染端守卫) */
.plb-sub{font-style:italic}
/* D-1 数字计数带 */
.plb-stats-row{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
.plb-stats-row li{text-align:center;padding:14px 8px}
.plb-num{display:block;font-size:34px;font-weight:700;line-height:1.1;color:var(--plb-primary);font-variant-numeric:tabular-nums}
.plb-unit{font-size:14px;font-weight:600;margin-left:2px}
.plb-stats-label{display:block;margin-top:6px;color:var(--plb-muted);font-size:13px}
/* D-2 图标宫格(quick_links 升级):形状由主题 iconShape 参数控制(circle/hexagon/square) */
.plb-quick-grid{grid-template-columns:repeat(6,1fr)}
.plb-qitem{display:flex;flex-direction:column;align-items:center;gap:8px;padding:14px 8px;text-decoration:none;background:var(--plb-page-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);transition:transform .2s,border-color .2s}
.plb-qitem:hover{transform:translateY(-3px);border-color:var(--plb-primary)}
.plb-qicon{display:inline-flex;align-items:center;justify-content:center;width:52px;height:52px;color:#fff;background:linear-gradient(135deg,var(--plb-primary),var(--plb-primary, #2d8cf0));border-radius:50%}
.plb-qicon svg{width:26px;height:26px}
.plb-shape-square .plb-qicon{border-radius:10px}
.plb-shape-hexagon .plb-qicon{clip-path:polygon(50% 0,93% 25%,93% 75%,50% 100%,7% 75%,7% 25%);border-radius:0}
.plb-qfall{font-size:22px;font-weight:700;line-height:1}
.plb-qname{font-size:14px;color:var(--plb-text)}
/* D-3 专题大卡 */
.plb-topics{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
.plb-topic{position:relative;display:block;border-radius:var(--plb-radius);overflow:hidden;text-decoration:none;min-height:220px;box-shadow:0 6px 20px rgba(15,34,66,.12)}
.plb-topic img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transition:transform .35s}
.plb-topic:hover img{transform:scale(1.04)}
.plb-topic-body{position:absolute;left:0;right:0;bottom:0;padding:40px 16px 14px;background:linear-gradient(180deg,transparent,rgba(0,0,0,.72));color:#fff}
.plb-topic-t{display:block;font-size:17px;font-weight:600}
.plb-topic-d{display:block;margin-top:4px;font-size:13px;opacity:.85;line-height:1.5}
.plb-topic-cta{display:inline-block;margin-top:10px;font-size:13px;border:1px solid rgba(255,255,255,.7);border-radius:16px;padding:3px 14px;color:#fff !important;text-decoration:none}
.plb-topic-cta:hover{background:rgba(255,255,255,.15)}
/* D-5 视频/VR 校园 */
.plb-video{position:relative;display:block;border-radius:var(--plb-radius);overflow:hidden;box-shadow:0 8px 26px rgba(15,34,66,.16)}
.plb-video img{width:100%;height:340px;object-fit:cover;display:block}
.plb-video-play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
.plb-video-play::before{content:"";position:absolute;width:74px;height:74px;border-radius:50%;background:rgba(0,0,0,.45);backdrop-filter:blur(2px)}
.plb-video-play::after{content:"";position:absolute;width:0;height:0;border-style:solid;border-width:14px 0 14px 24px;border-color:transparent transparent transparent #fff;margin-left:6px}
/* D-6 学术活动(大日期块横带) */
.plb-events{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}
.plb-event{display:flex;gap:14px;align-items:center;padding:14px 16px;background:var(--plb-card-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);text-decoration:none}
.plb-event-date{flex:none;width:64px;text-align:center;color:var(--plb-primary)}
.plb-event-date b{display:block;font-size:30px;line-height:1;font-weight:700}
.plb-event-date i{display:block;font-style:normal;font-size:11px;color:var(--plb-muted);margin-top:3px}
.plb-event-t{display:block;color:var(--plb-text);font-size:15px;font-weight:600;line-height:1.4}
.plb-event-loc{display:block;color:var(--plb-muted);font-size:12px;margin-top:3px}
.plb-event:hover .plb-event-t{color:var(--plb-primary)}
/* D-7 名师风采 */
.plb-people{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:16px}
.plb-person{display:flex;flex-direction:column;align-items:center;text-align:center;padding:20px 12px;background:var(--plb-card-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius)}
.plb-person-avatar{width:84px;height:84px;border-radius:50%;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--plb-primary),#8fb8f0);color:#fff;font-size:32px;font-weight:700}
.plb-person-avatar img{width:100%;height:100%;object-fit:cover}
.plb-person-name{margin-top:12px;font-size:16px;font-weight:600;color:var(--plb-text)}
.plb-person-title{margin-top:2px;font-size:12px;color:var(--plb-primary)}
.plb-person-bio{margin-top:8px;font-size:13px;color:var(--plb-muted);line-height:1.6}
/* D-8 campus_wall 瀑布流(columns 流式,块高天然错落) */
.plb-gallery-grid.masonry{display:block;columns:4;column-gap:14px}
.plb-gallery-grid.masonry li{break-inside:avoid;margin-bottom:14px}
.plb-gallery-grid.masonry img{height:auto}
/* D-9 徽章带 */
.plb-badges{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;justify-content:center;gap:28px}
.plb-badge-item{display:flex;flex-direction:column;align-items:center;text-align:center;max-width:130px}
.plb-badge-item img{width:72px;height:72px;object-fit:contain}
.plb-badge-label{margin-top:8px;font-size:14px;font-weight:600;color:var(--plb-text)}
.plb-badge-desc{margin-top:2px;font-size:12px;color:var(--plb-muted)}
/* D-8/D-42 骨架共享入场动效(R-42:所有主题自动生效,主题参数 animation=off 关闭) */
.plb-anim{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease}
.plb-anim.plb-in{opacity:1;transform:none}
@media (prefers-reduced-motion: reduce){.plb-anim{opacity:1;transform:none;transition:none}}
main > .plb-anchor > .plb-stats-band{grid-column:1 / -1}
main > .plb-anchor > .plb-section{grid-column:1 / -1}
@media (max-width:768px){
.plb-stats-row{grid-template-columns:repeat(2,1fr)}
.plb-topics{grid-template-columns:1fr}
.plb-video img{height:210px}
.plb-gallery-grid.masonry{columns:2}
.plb-quick-grid{grid-template-columns:repeat(3,1fr)}
.plb-people{grid-template-columns:repeat(2,1fr)}
.plb-badges{gap:16px}
}
@media (max-width:768px){
.plb-grid2{columns:1}
.plb-imagewall{grid-template-columns:repeat(2,1fr)}
@@ -118,6 +202,13 @@ main > .plb-notice-bar{grid-column:1 / -1}
<th:block th:case="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
<th:block th:case="'quick_links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbQuickLinks(${block})}" /></th:block>
<th:block th:case="'notice_bar'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbNoticeBar(${block})}" /></th:block>
<!-- docs/20 D-1~D-9 新组件(骨架兜底渲染;A 档主题可自持精细版覆盖) -->
<th:block th:case="'stats_band'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbStatsBand(${block})}" /></th:block>
<th:block th:case="'featured_topics'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbFeaturedTopics(${block})}" /></th:block>
<th:block th:case="'video_tour'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbVideoTour(${block})}" /></th:block>
<th:block th:case="'events'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbEvents(${block})}" /></th:block>
<th:block th:case="'people'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbPeople(${block})}" /></th:block>
<th:block th:case="'badges'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbBadges(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCard(${block})}" /></th:block>
</th:block>
</th:block>
@@ -126,7 +217,8 @@ main > .plb-notice-bar{grid-column:1 / -1}
<div class="plb-head" th:fragment="head(block)">
<div>
<h2 th:text="${block.title}">区块标题</h2>
<p class="plb-sub" th:if="${!#strings.isEmpty(block['subtitle'])}" th:text="${block['subtitle']}">副题</p>
<!-- D-4(docs/20):中英双语副题;themeParams.titleStyle=cn 时隐藏(主题级开关) -->
<p class="plb-sub" th:if="${!#strings.isEmpty(block['subtitle']) and (themeParams == null or themeParams['titleStyle'] == null or themeParams['titleStyle'] != 'cn')}" th:text="${block['subtitle']}">副题</p>
</div>
<a class="plb-more" th:if="${!#strings.isEmpty(block['moreUrl'])}" th:href="${block['moreUrl']}">更多 &gt;</a>
</div>
@@ -266,7 +358,8 @@ main > .plb-notice-bar{grid-column:1 / -1}
<h2 th:text="${block.title}">线上展厅</h2>
<p class="plb-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">Online Exhibition Hall</p>
</div>
<ul class="plb-gallery-grid">
<!-- D-8(docs/20):layout=masonry 时瀑布流(columns 错落) -->
<ul class="plb-gallery-grid" th:classappend="${block['layout'] == 'masonry' ? 'masonry' : ''}">
<li th:each="g : ${block.items}">
<a th:href="${g.linkUrl}" th:title="${g.title}">
<img th:src="${g.imageUrl}" th:alt="${g.title}">
@@ -300,9 +393,20 @@ main > .plb-notice-bar{grid-column:1 / -1}
<div class="plb-container">
<div class="plb-card">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-quick-grid" th:if="${!#lists.isEmpty(block['quickLinks'])}">
<!-- D-2(docs/20):图标宫格——内置 SVG 图标集,未选图标降级首字徽章;形状随主题 iconShape 参数 -->
<ul class="plb-quick-grid" th:with="shapeCls=${themeParams != null and themeParams['iconShape'] != null ? 'plb-shape-' + themeParams['iconShape'] : 'plb-shape-circle'}"
th:if="${!#lists.isEmpty(block['quickLinks'])}">
<li th:each="l : ${block['quickLinks']}">
<a class="plb-quick-link" th:href="${l.url}" th:text="${l.name}">入口名</a>
<a class="plb-qitem" th:classappend="${shapeCls}" th:href="${l.url}">
<span class="plb-qicon">
<th:block th:if="${!#strings.isEmpty(l['icon'])}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: icon(${l['icon']})}" />
</th:block>
<span class="plb-qfall" th:unless="${!#strings.isEmpty(l['icon'])}"
th:text="${#strings.length(l.name) > 0 ? #strings.substring(l.name, 0, 1) : ''}">首</span>
</span>
<span class="plb-qname" th:text="${l.name}">入口名</span>
</a>
</li>
</ul>
<!-- 空态守卫必须包在 th:block 外层(坑①:th:replace 优先级 100 先于 th:if 300 求值) -->
@@ -326,3 +430,285 @@ main > .plb-notice-bar{grid-column:1 / -1}
th:text="${block['noticeText']}">公告正文</span>
</div>
</div>
<!--
F2(docs/20)实时预览监听脚本:管理端首页布局编辑器(iframe 加载正式门户)经 postMessage
发送未保存的编排模拟——{type:'cms-layout-preview', order:[区块id…], cssVars:{…}}。
行为:按 order 重排/隐藏 [data-plb-id] 锚点(display:contents 包裹层,零布局影响);
未知 id(未保存的新区块)静默忽略 → 编辑器侧提供手动刷新兜底(D-3 降级路径)。
安全:仅 DOM 移动/显隐与 CSS 变量赋值(textContent/innerHTML 一概不碰),payload 严格校形。
引用位置:主题 home.html 布局分支随 :: style 一并引入一次。
-->
<th:block th:fragment="previewScript">
<script>
(function () {
if (window.__cmsLayoutPreviewBound) { return; }
window.__cmsLayoutPreviewBound = true;
window.addEventListener('message', function (ev) {
var data = ev.data;
if (!data || data.type !== 'cms-layout-preview') { return; }
var anchors = Array.prototype.slice.call(document.querySelectorAll('[data-plb-id]'));
if (!anchors.length) { return; }
var order = Array.isArray(data.order) ? data.order.filter(function (x) {
return typeof x === 'string' && x.length <= 64;
}) : null;
if (order) {
var byId = {};
anchors.forEach(function (a) { byId[a.getAttribute('data-plb-id')] = a; });
var parent = anchors[0].parentNode;
order.forEach(function (id) {
var a = byId[id];
if (a) { a.style.display = 'contents'; parent.appendChild(a); }
});
anchors.forEach(function (a) {
if (order.indexOf(a.getAttribute('data-plb-id')) < 0) { a.style.display = 'none'; }
});
}
if (data.cssVars && typeof data.cssVars === 'object') {
var bad = /[^a-zA-Z0-9-_, .#%()]/;
Object.keys(data.cssVars).forEach(function (k) {
if (/^--[a-zA-Z][\w-]*$/.test(k) && !bad.test(String(data.cssVars[k]))) {
document.documentElement.style.setProperty(k, data.cssVars[k]);
}
});
}
});
})();
</script>
</th:block>
<!--
===== docs/20 D-1~D-9 新组件骨架 fragment(追加于文件尾部) =====
契约键:stats_band.stats / featured_topics.items[{title,description,imageUrl,linkUrl,ctaText,ctaUrl}] /
video_tour.{cover,videoUrl} / events.items[{title,linkUrl,eventDate,location}] /
people.items[{name,title,bio,avatar}] / badges.items[{label,desc,image}]。
空数据由渲染端直接不产出区块(R-44),此处守卫为双保险。
-->
<!-- D-1 数字计数带:count-up 动画由共享动效脚本驱动(data-plb-countup) -->
<section class="plb-section plb-stats-band" th:fragment="plbStatsBand(block)" aria-label="数字计数">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-stats-row" th:if="${!#lists.isEmpty(block['stats'])}">
<li th:each="st : ${block['stats']}">
<span class="plb-num">
<span class="plb-count" th:attr="data-plb-countup=${st['value']}" th:text="${st['value']}">3.2</span><span
class="plb-unit" th:if="${!#strings.isEmpty(st['unit'])}" th:text="${st['unit']}">万</span>
</span>
<span class="plb-stats-label" th:text="${st['label']}">在校生</span>
</li>
</ul>
</div>
</section>
<!-- D-3 专题大卡:大图 + 标题 + 描述 + CTA 按钮(数据 = 轮播管理「专题大卡」位)。
整卡主链接 = linkUrl;CTA 按钮 = ctaUrl(无 ctaUrl 时回落 linkUrl),span+data-href 由脚本转 a 语义防止嵌套 a -->
<section class="plb-section" th:fragment="plbFeaturedTopics(block)" aria-label="专题推荐" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<div class="plb-topics">
<a class="plb-topic" th:each="t : ${block.items}" th:href="${!#strings.isEmpty(t['ctaUrl']) ? t['ctaUrl'] : t['linkUrl']}">
<img th:src="${t['imageUrl']}" th:alt="${t['title']}">
<span class="plb-topic-body">
<span class="plb-topic-t" th:text="${t['title']}">专题标题</span>
<span class="plb-topic-d" th:if="${!#strings.isEmpty(t['description'])}" th:text="${t['description']}">专题描述</span>
<span class="plb-topic-cta" th:if="${!#strings.isEmpty(t['ctaText'])}" th:text="${t['ctaText']}">进入专题</span>
</span>
</a>
</div>
</div>
</section>
<!-- D-5 视频/VR 校园:封面大图 + 播放按钮(点击开新窗跳视频/VR 地址) -->
<section class="plb-section" th:fragment="plbVideoTour(block)" aria-label="云端校园">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<a class="plb-video" th:href="${block['videoUrl']}" target="_blank" rel="noopener">
<img th:src="${block['cover']}" th:alt="${block['title']}">
<span class="plb-video-play"></span>
</a>
</div>
</section>
<!-- D-6 学术日历/活动:大日期块(日 / 年月两段)+ 活动名 + 地点(数据 = 轮播管理「学术活动」位) -->
<section class="plb-section" th:fragment="plbEvents(block)" aria-label="学术活动" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-events">
<li th:each="e : ${block.items}">
<a class="plb-event" th:href="${e['linkUrl']}">
<span class="plb-event-date" th:if="${e['eventDate'] != null}">
<b th:text="${#temporals.format(e['eventDate'], 'dd')}">28</b>
<i th:text="${#temporals.format(e['eventDate'], 'yyyy-MM')}">2026-09</i>
</span>
<span class="plb-event-date" th:unless="${e['eventDate'] != null}">
<b>·</b><i>待定</i>
</span>
<span>
<span class="plb-event-t" th:text="${e['title']}">活动名称</span>
<span class="plb-event-loc" th:if="${!#strings.isEmpty(e['location'])}" th:text="${e['location']}">活动地点</span>
</span>
</a>
</li>
</ul>
</div>
</section>
<!-- D-7 名师风采:圆头像(无头像首字徽章)+ 姓名 + 头衔 + 简介(数据 = 人物管理) -->
<section class="plb-section" th:fragment="plbPeople(block)" aria-label="名师风采" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-people">
<li class="plb-person" th:each="pp : ${block.items}">
<span class="plb-person-avatar">
<img th:if="${!#strings.isEmpty(pp['avatar'])}" th:src="${url.upload(pp['avatar'])}" th:alt="${pp['name']}">
<span th:unless="${!#strings.isEmpty(pp['avatar'])}"
th:text="${#strings.length(pp['name']) > 0 ? #strings.substring(pp['name'], 0, 1) : ''}">师</span>
</span>
<span class="plb-person-name" th:text="${pp['name']}">姓名</span>
<span class="plb-person-title" th:if="${!#strings.isEmpty(pp['title'])}" th:text="${pp['title']}">头衔</span>
<span class="plb-person-bio" th:if="${!#strings.isEmpty(pp['bio'])}" th:text="${pp['bio']}">一句话简介</span>
</li>
</ul>
</div>
</section>
<!-- D-9 排名/学科评估徽章带 -->
<section class="plb-section" th:fragment="plbBadges(block)" aria-label="实力声誉" th:if="${!#lists.isEmpty(block.items)}">
<div class="plb-container">
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
<ul class="plb-badges">
<li class="plb-badge-item" th:each="bd : ${block.items}">
<img th:src="${url.upload(bd['image'])}" th:alt="${bd['label']}">
<span class="plb-badge-label" th:text="${bd['label']}">软科排名</span>
<span class="plb-badge-desc" th:if="${!#strings.isEmpty(bd['desc'])}" th:text="${bd['desc']}">前 100 强</span>
</li>
</ul>
</div>
</section>
<!--
D-2 内置通用 SVG 图标集(docs/20 §3.3 定案 2:平台内置约 20 个,不引外部 CDN)。
24x24 线条风格,stroke=currentColor;管理端图标选择器同 key 对应(front IconGrid)。
SpEL 对 Map 缺失键访问会抛错——l['icon'] 的 Map 取值安全(缺键返回 null),不用的 g 分支整组不渲染。
-->
<svg th:fragment="icon(name)" class="plb-svg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<!-- 招生(学士帽) -->
<g th:if="${name == 'zhaosheng'}"><path d="M3 9.5 12 4.5 21 9.5 12 14.5 3 9.5z"/><path d="M7 12v4.5c0 1 2.2 2 5 2s5-1 5-2V12"/><path d="M21 9.5V15"/></g>
<!-- 就业(公文包) -->
<g th:if="${name == 'jiuye'}"><rect x="3.5" y="8" width="17" height="11.5" rx="2"/><path d="M9 8V6.2C9 5.5 9.6 5 10.3 5h3.4c.7 0 1.3.5 1.3 1.2V8"/><path d="M3.5 12.5h17"/></g>
<!-- 图书馆(书) -->
<g th:if="${name == 'tushuguan'}"><path d="M4 19.5 4.9 6.5 11 5v15"/><path d="M11 20h9V8l-7.9-1.5"/><path d="M4 19.5h7"/><path d="M14.5 11.5h3M14.5 15h3"/></g>
<!-- 办事大厅(对话) -->
<g th:if="${name == 'banshi'}"><path d="M4 5.5h16v10H9.5L5 19.2V15.5H4z"/></g>
<!-- 信息公开(眼睛) -->
<g th:if="${name == 'xinxi'}"><path d="M2.5 12C5 7.5 8.3 5.5 12 5.5S19 7.5 21.5 12C19 16.5 15.7 18.5 12 18.5S5 16.5 2.5 12z"/><circle cx="12" cy="12" r="3"/></g>
<!-- 校友(人群) -->
<g th:if="${name == 'xiaoyou'}"><circle cx="9" cy="8.5" r="3"/><path d="M3.5 19c0-3 2.5-5 5.5-5s5.5 2 5.5 5"/><circle cx="17" cy="9.5" r="2.2"/><path d="M16 14.2c2.6.3 4.5 2.1 4.5 4.8"/></g>
<!-- 邮箱 -->
<g th:if="${name == 'youjian'}"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3.5 7 8.5 6 8.5-6"/></g>
<!-- 电话 -->
<g th:if="${name == 'dianhua'}"><path d="M5 4h3.5l1.7 4.3-2.3 1.8a12.5 12.5 0 0 0 5.9 5.9l1.8-2.3L20 15.4V19a1.5 1.5 0 0 1-1.6 1.5C10 20 4 14 3.5 5.6A1.5 1.5 0 0 1 5 4z"/></g>
<!-- 地图(定位) -->
<g th:if="${name == 'ditu'}"><path d="M12 21.5c4.2-5 6.8-8 6.8-11.5a6.8 6.8 0 1 0-13.6 0c0 3.5 2.6 6.5 6.8 11.5z"/><circle cx="12" cy="9.7" r="2.4"/></g>
<!-- 新闻 -->
<g th:if="${name == 'xinwen'}"><rect x="3" y="5" width="18" height="14" rx="1.5"/><path d="M6.5 9h6M6.5 12.5h6M6.5 16h6"/><rect x="15.5" y="9" width="3" height="7.5" rx=".5"/></g>
<!-- 日历 -->
<g th:if="${name == 'rili'}"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></g>
<!-- 视频 -->
<g th:if="${name == 'shipin'}"><circle cx="12" cy="12" r="8.5"/><path d="M10 8.5 16 12l-6 3.5z"/></g>
<!-- 微信 -->
<g th:if="${name == 'weixin'}"><path d="M9.5 5.5C6 5.5 3 7.8 3 10.7c0 1.6.9 3 2.3 4L5 17l2.6-1.3c.6.2 1.3.3 1.9.3"/><path d="M21.5 14.8c0-2.4-2.3-4.3-5.1-4.3s-5.1 1.9-5.1 4.3 2.3 4.3 5.1 4.3c.6 0 1.2-.1 1.7-.3L20.5 20l-.5-1.7c.9-.8 1.5-2.1 1.5-3.5z"/></g>
<!-- 微博 -->
<g th:if="${name == 'weibo'}"><path d="M10.5 19.5c-3.5 0-6.5-2-6.5-4.7 0-2.9 3.3-5.6 6.8-5.6 3 0 6.5 1.6 6.5 4.2 0 2.3-1.9 4.3-4.4 4.3-1.8 0-3.6-.8-3.6-2 0-.8.7-1.5 1.7-1.5.9 0 1.5.5 1.5 1.1 0 .4-.3.7-.7.8"/><path d="M15.5 4.5c2.9 0 5 1.8 5.2 4.3"/><path d="M15.8 7.2c1.5 0 2.7.9 2.8 2.3"/></g>
<!-- 抖音(音符) -->
<g th:if="${name == 'douyin'}"><circle cx="8" cy="17.5" r="3"/><path d="M11 17.5V4.5l7 2v3.2l-4.6-1.3"/></g>
<!-- B站 -->
<g th:if="${name == 'bilibili'}"><rect x="3" y="6.5" width="18" height="12.5" rx="3"/><path d="M7 4l2.2 2.4M17 4l-2.2 2.4"/><path d="M9.2 10.5v4M14.8 10.5v4"/></g>
<!-- 链接 -->
<g th:if="${name == 'lianjie'}"><path d="m9.5 14.5 5-5"/><path d="m8 12-3 3a4 4 0 0 0 5.7 5.7l3-3"/><path d="m16 12 3-3a4 4 0 0 0-5.7-5.7l-3 3"/></g>
<!-- 档案 -->
<g th:if="${name == 'dangan'}"><path d="M6 3.5h8.5L18 7v13.5H6z"/><path d="M14 3.5V7h4"/><path d="M9 11h6M9 14.5h6M9 18h3.5"/></g>
<!-- 校历/校史(建筑) -->
<g th:if="${name == 'xiaoli'}"><path d="M4 21V10l8-6.2L20 10v11"/><path d="M10 21v-6h4v6"/><path d="M4 21h16"/><path d="M9.5 10.5h5"/></g>
<!-- 体育(奖杯) -->
<g th:if="${name == 'tiyu'}"><path d="M8 4.5h8v5a4 4 0 0 1-8 0z"/><path d="M8 5.5H5v1.6A3.4 3.4 0 0 0 8.4 10M16 5.5h3v1.6A3.4 3.4 0 0 1 15.6 10"/><path d="M12 13.5v3M8.5 20h7M9.8 20c0-1.7.9-3.5 2.2-3.5s2.2 1.8 2.2 3.5"/></g>
<!-- 艺术(调色板) -->
<g th:if="${name == 'yishu'}"><path d="M12 3a9 9 0 0 0 0 18c1.4 0 2-.8 2-1.7 0-1.5-1.6-1.9-1.6-3.1 0-1 .8-1.7 2-1.7h1.8A4.8 4.8 0 0 0 21 9.7C21 5.9 17 3 12 3z"/><circle cx="7.8" cy="10.5" r=".9"/><circle cx="12" cy="7.6" r=".9"/><circle cx="16.2" cy="10" r=".9"/></g>
<!-- 实验(烧瓶) -->
<g th:if="${name == 'shiyan'}"><path d="M10 3.5h4M11 3.5v6l-4.6 8.2A2 2 0 0 0 8.2 20.5h7.6a2 2 0 0 0 1.8-2.8L13 9.5v-6"/><path d="M8.5 14.5h7"/></g>
</svg>
<!--
D-8 配套 · 骨架共享动效脚本(R-42):滚动入场(fade-in-up + stagger)+ 数字计数 count-up。
一处实现全局生效(D-8 决策),主题经 theme.json 参数 animation=off 关闭(由引用方 th:if 守卫);
尊重 prefers-reduced-motion。观察目标 = main 的区块级直接子元素(.plb-anchor 子节点或 main 直属区块根)。
-->
<th:block th:fragment="motionScript">
<script>
(function () {
if (window.__cmsMotionBound) { return; }
window.__cmsMotionBound = true;
var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) { return; }
function targets() {
var main = document.querySelector('main');
if (!main) { return []; }
var out = [];
Array.prototype.forEach.call(main.children, function (n) {
if (n.classList && n.classList.contains('plb-anchor')) {
Array.prototype.forEach.call(n.children, function (c) { out.push(c); });
} else if (n.tagName === 'SECTION' || (n.tagName === 'DIV' && n.getAttribute('role') === 'note')) {
out.push(n);
}
});
return out;
}
var els = targets();
els.forEach(function (el, i) {
el.classList.add('plb-anim');
el.style.transitionDelay = Math.min(i * 80, 400) + 'ms';
});
if (!('IntersectionObserver' in window)) {
els.forEach(function (el) { el.classList.add('plb-in'); });
} else {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) {
en.target.classList.add('plb-in');
io.unobserve(en.target);
}
});
}, { threshold: 0.12 });
els.forEach(function (el) { io.observe(el); });
}
document.querySelectorAll('[data-plb-countup]').forEach(function (node) {
var raw = (node.textContent || '').trim();
var m = raw.match(/^([^0-9]*)([0-9,]+(?:\.[0-9]+)?)(.*)$/);
if (!m) { return; }
var prefix = m[1], numText = m[2].replace(/,/g, ''), suffix = m[3];
var target = parseFloat(numText);
if (isNaN(target)) { return; }
var decimals = (numText.split('.')[1] || '').length;
var t0 = null, dur = 1200;
function step(ts) {
if (!t0) { t0 = ts; }
var p = Math.min((ts - t0) / dur, 1);
var eased = 1 - Math.pow(1 - p, 3);
node.textContent = prefix + (target * eased).toFixed(decimals) + suffix;
if (p < 1) { requestAnimationFrame(step); }
}
var cio = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) {
cio.disconnect();
requestAnimationFrame(step);
}
});
}, { threshold: 0.4 });
cio.observe(node);
});
})();
</script>
</th:block>
@@ -0,0 +1,77 @@
<!--
D-10(docs/20)搜索浮层 · 通用 fragment:theme.json 参数 searchStyle 控制门户搜索形态——
· inline(缺省)= 保留主题 header 自有内联搜索表单(现状外观,零变化)
· none = 不渲染任何搜索入口
· overlay = 隐藏内联表单,渲染右上「放大镜」按钮 + 全屏搜索浮层(本文件实现)
引用方式:主题 header.html 在其内联搜索表单位置——
<th:block th:if="${themeParams != null and themeParams['searchStyle'] == 'overlay'}"
th:replace="~{'themes/common/fragments/search' :: overlay(${url})}" />
<form class="…" th:unless="${themeParams != null and themeParams['searchStyle'] == 'overlay'}" …>(inline/缺省保留原表单)
样式/脚本自持(不引外部依赖);颜色跟随主题变量缺省蓝。
-->
<th:block th:fragment="overlay(url)">
<div class="plb-search-widget">
<button type="button" class="plb-search-toggle" aria-label="打开站内搜索" aria-expanded="false">
<svg viewBox="0 0 24 24" width="19" height="19" focusable="false" aria-hidden="true">
<path d="M10.5 3a7.5 7.5 0 015.9 12.1l4.3 4.3-1.4 1.4-4.3-4.3A7.5 7.5 0 1110.5 3zm0 2a5.5 5.5 0 100 11 5.5 5.5 0 000-11z"
fill="currentColor"></path>
</svg>
<span>搜索</span>
</button>
<div class="plb-search-layer" role="dialog" aria-modal="true" aria-label="站内搜索">
<button type="button" class="plb-search-close" aria-label="关闭搜索">✕</button>
<form class="plb-search-form" th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="输入关键词,搜索本站内容…" aria-label="站内搜索" autocomplete="off">
<button type="submit">搜 索</button>
</form>
<p class="plb-search-tip">按 ESC 或点击空白处关闭</p>
</div>
</div>
<style>
.plb-search-widget{position:relative}
.plb-search-toggle{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border, #e5e6eb);
background:transparent;color:inherit;border-radius:18px;padding:5px 14px;font-size:13px;cursor:pointer;
transition:border-color .2s,color .2s}
.plb-search-toggle:hover{border-color:var(--primary, #2d8cf0);color:var(--primary, #2d8cf0)}
.plb-search-layer{position:fixed;inset:0;z-index:9999;display:none;align-items:center;justify-content:center;
flex-direction:column;background:rgba(10,20,40,.82);backdrop-filter:blur(6px)}
.plb-search-widget.open .plb-search-layer{display:flex}
.plb-search-close{position:absolute;top:26px;right:32px;background:none;border:0;color:#fff;font-size:22px;
cursor:pointer;opacity:.8;padding:6px}
.plb-search-close:hover{opacity:1}
.plb-search-form{display:flex;width:min(640px,86vw);border-radius:8px;overflow:hidden;box-shadow:0 18px 60px rgba(0,0,0,.35)}
.plb-search-form input{flex:1;border:0;padding:16px 18px;font-size:16px;outline:none;min-width:0}
.plb-search-form button{border:0;padding:0 26px;font-size:15px;cursor:pointer;
background:var(--primary, #2d8cf0);color:#fff}
.plb-search-form button:hover{filter:brightness(1.08)}
.plb-search-tip{margin-top:16px;color:rgba(255,255,255,.55);font-size:12px;letter-spacing:.05em}
</style>
<script>
(function () {
var widget = document.currentScript && document.currentScript.previousElementSibling;
while (widget && !(widget.classList && widget.classList.contains('plb-search-widget'))) {
widget = widget && widget.previousElementSibling;
}
if (!widget || widget.__plbSearchBound) { return; }
widget.__plbSearchBound = true;
var toggle = widget.querySelector('.plb-search-toggle');
var layer = widget.querySelector('.plb-search-layer');
var input = widget.querySelector('.plb-search-form input');
function open() {
widget.classList.add('open');
toggle.setAttribute('aria-expanded', 'true');
if (input) { setTimeout(function () { input.focus(); }, 50); }
}
function close() {
widget.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
toggle.addEventListener('click', open);
widget.querySelector('.plb-search-close').addEventListener('click', close);
layer.addEventListener('click', function (e) { if (e.target === layer) { close(); } });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); }
});
})();
</script>
</th:block>
@@ -34,10 +34,19 @@
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
<a class="site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="site-search" th:action="${url.search('', null)}" method="get" role="search">
<!-- D-10(docs/20):searchStyle=overlay 换全屏搜索浮层;none 隐藏;inline/缺省保留原表单。
坑④:th:replace 优先级(100)先于 th:if(300)——条件必须放外层 th:block,replace 放子元素 -->
<th:block th:if="${themeParams != null and themeParams['searchStyle'] == 'overlay'}">
<th:block th:replace="~{'themes/common/fragments/search' :: overlay(${url})}" />
</th:block>
<th:block th:if="${themeParams == null or themeParams['searchStyle'] == null or themeParams['searchStyle'] == 'inline'}">
<form class="site-search"
th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
<button type="submit">搜索</button>
</form>
</th:block>
</div>
<!-- 栏目导航:外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
@@ -26,8 +26,12 @@
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染(common 兜底骨架,本主题不另设形态) -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: previewScript}" />
<!-- F2:data-plb-id 锚点(display:contents)供编辑器实时预览 postMessage 重排 -->
<th:block th:each="block : ${layoutBlocks}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
<div class="plb-anchor" th:attr="data-plb-id=${block['id']}" style="display:contents">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" />
</div>
</th:block>
</th:block>
@@ -8,12 +8,126 @@
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#1a5cb0" },
{ "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#12447f" },
{ "key": "text", "cssVar": "--text", "label": "正文文字色", "type": "color", "default": "#333333" },
{ "key": "textLight", "cssVar": "--text-light", "label": "次要文字色", "type": "color", "default": "#777777" },
{ "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f5f6f8" },
{ "key": "cardBg", "cssVar": "--card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#e5e6eb" }
{
"key": "primary",
"cssVar": "--primary",
"label": "主色",
"type": "color",
"default": "#1a5cb0"
},
{
"key": "primaryDark",
"cssVar": "--primary-dark",
"label": "主色加深",
"type": "color",
"default": "#12447f"
},
{
"key": "text",
"cssVar": "--text",
"label": "正文文字色",
"type": "color",
"default": "#333333"
},
{
"key": "textLight",
"cssVar": "--text-light",
"label": "次要文字色",
"type": "color",
"default": "#777777"
},
{
"key": "bg",
"cssVar": "--bg",
"label": "页面底色",
"type": "color",
"default": "#f5f6f8"
},
{
"key": "cardBg",
"cssVar": "--card-bg",
"label": "卡片底色",
"type": "color",
"default": "#ffffff"
},
{
"key": "border",
"cssVar": "--border",
"label": "边框色",
"type": "color",
"default": "#e5e6eb"
},
{
"key": "animation",
"label": "首页动效",
"type": "select",
"default": "on",
"options": [
{
"value": "on",
"label": "开启(滚动入场 + 数字计数)"
},
{
"value": "off",
"label": "关闭(静态呈现)"
}
]
},
{
"key": "titleStyle",
"label": "栏目标题语言",
"type": "select",
"default": "cn",
"options": [
{
"value": "cn",
"label": "仅中文"
},
{
"value": "bilingual",
"label": "中英双语(大中文 + 斜体英文副题)"
}
]
},
{
"key": "iconShape",
"label": "图标形状语言",
"type": "select",
"default": "circle",
"options": [
{
"value": "circle",
"label": "圆形徽章"
},
{
"value": "hexagon",
"label": "六边形"
},
{
"value": "square",
"label": "圆角方形"
}
]
},
{
"key": "searchStyle",
"label": "门户搜索样式",
"type": "select",
"default": "inline",
"options": [
{
"value": "none",
"label": "不显示搜索"
},
{
"value": "inline",
"label": "通栏搜索条(banner 下方白色大搜索条)"
},
{
"value": "overlay",
"label": "全屏搜索浮层(右上放大镜唤起)"
}
]
}
]
}
@@ -44,7 +44,15 @@
</span>
<a class="sp-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
</div>
<form class="sp-search" th:action="${url.search('', null)}" method="get" role="search">
<!-- D-10(docs/20):searchStyle=overlay 换全屏搜索浮层;none 隐藏;inline/缺省保留原表单。
坑④:th:replace 优先级(100)先于 th:if(300)——条件必须放外层 th:block,replace 放子元素 -->
<th:block th:if="${themeParams != null and themeParams['searchStyle'] == 'overlay'}">
<th:block th:replace="~{'themes/common/fragments/search' :: overlay(${url})}" />
</th:block>
<th:block th:if="${themeParams == null or themeParams['searchStyle'] == null or themeParams['searchStyle'] == 'inline'}">
<form class="sp-search"
th:action="${url.search('', null)}" method="get" role="search">
<input type="text" name="q" placeholder="请输入关键字…" aria-label="站内搜索">
<button type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">
@@ -53,6 +61,7 @@
</svg>
</button>
</form>
</th:block>
</div>
</div>
@@ -30,7 +30,9 @@
否则未命中的 case 分支也会求值 fragment 表达式(缺键 Map 访问直接抛错整页 500)。 -->
<th:block th:if="${layoutBlocks != null}">
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: style}" />
<th:block th:replace="~{'themes/common/fragments/home-blocks' :: previewScript}" />
<th:block th:each="block : ${layoutBlocks}">
<div class="plb-anchor" th:attr="data-plb-id=${block['id']}" style="display:contents">
<th:block th:switch="${block.type}">
<th:block th:case="'article_list'">
<th:block th:switch="${block.homeStyle}">
@@ -48,6 +50,7 @@
<th:block th:case="'notice_bar'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: notice_bar(${block})}" /></th:block>
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
</th:block>
</div>
</th:block>
</th:block>
@@ -8,20 +8,163 @@
"screenshot": "assets/screenshot.png",
"bannerSize": "1920x560",
"params": [
{ "key": "primary", "cssVar": "--sp-primary", "label": "主色", "type": "color", "default": "#0a5aa8" },
{ "key": "primaryDark", "cssVar": "--sp-primary-dark", "label": "主色加深", "type": "color", "default": "#033d70" },
{ "key": "primaryLight", "cssVar": "--sp-primary-light","label": "主色提亮", "type": "color", "default": "#1167bb" },
{ "key": "navBg", "cssVar": "--sp-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
{ "key": "footerBg", "cssVar": "--sp-footer-bg", "label": "页脚底色", "type": "color", "default": "#0b4781" },
{ "key": "text", "cssVar": "--sp-text", "label": "正文文字色", "type": "color", "default": "#333333" },
{ "key": "textLight", "cssVar": "--sp-text-light", "label": "次要文字色", "type": "color", "default": "#777777" },
{ "key": "bg", "cssVar": "--sp-bg", "label": "页面底色", "type": "color", "default": "#eff2f6" },
{ "key": "cardBg", "cssVar": "--sp-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
{ "key": "border", "cssVar": "--sp-border", "label": "边框色", "type": "color", "default": "#e3e7ee" },
{ "key": "headerStyle", "label": "头部样式", "type": "select", "default": "banner",
{
"key": "primary",
"cssVar": "--sp-primary",
"label": "主色",
"type": "color",
"default": "#0a5aa8"
},
{
"key": "primaryDark",
"cssVar": "--sp-primary-dark",
"label": "主色加深",
"type": "color",
"default": "#033d70"
},
{
"key": "primaryLight",
"cssVar": "--sp-primary-light",
"label": "主色提亮",
"type": "color",
"default": "#1167bb"
},
{
"key": "navBg",
"cssVar": "--sp-nav-bg",
"label": "导航条底色",
"type": "color",
"default": "#ffffff"
},
{
"key": "footerBg",
"cssVar": "--sp-footer-bg",
"label": "页脚底色",
"type": "color",
"default": "#0b4781"
},
{
"key": "text",
"cssVar": "--sp-text",
"label": "正文文字色",
"type": "color",
"default": "#333333"
},
{
"key": "textLight",
"cssVar": "--sp-text-light",
"label": "次要文字色",
"type": "color",
"default": "#777777"
},
{
"key": "bg",
"cssVar": "--sp-bg",
"label": "页面底色",
"type": "color",
"default": "#eff2f6"
},
{
"key": "cardBg",
"cssVar": "--sp-card-bg",
"label": "卡片底色",
"type": "color",
"default": "#ffffff"
},
{
"key": "border",
"cssVar": "--sp-border",
"label": "边框色",
"type": "color",
"default": "#e3e7ee"
},
{
"key": "headerStyle",
"label": "头部样式",
"type": "select",
"default": "banner",
"options": [
{ "value": "banner", "label": "深蓝顶栏+品牌行+白底导航(完整学院派)" },
{ "value": "plain", "label": "简洁顶栏(收起深蓝细条、压低品牌行)" }
] }
{
"value": "banner",
"label": "深蓝顶栏+品牌行+白底导航(完整学院派)"
},
{
"value": "plain",
"label": "简洁顶栏(收起深蓝细条、压低品牌行)"
}
]
},
{
"key": "animation",
"label": "首页动效",
"type": "select",
"default": "on",
"options": [
{
"value": "on",
"label": "开启(滚动入场 + 数字计数)"
},
{
"value": "off",
"label": "关闭(静态呈现)"
}
]
},
{
"key": "titleStyle",
"label": "栏目标题语言",
"type": "select",
"default": "cn",
"options": [
{
"value": "cn",
"label": "仅中文"
},
{
"value": "bilingual",
"label": "中英双语(大中文 + 斜体英文副题)"
}
]
},
{
"key": "iconShape",
"label": "图标形状语言",
"type": "select",
"default": "circle",
"options": [
{
"value": "circle",
"label": "圆形徽章"
},
{
"value": "hexagon",
"label": "六边形"
},
{
"value": "square",
"label": "圆角方形"
}
]
},
{
"key": "searchStyle",
"label": "门户搜索样式",
"type": "select",
"default": "inline",
"options": [
{
"value": "none",
"label": "不显示搜索"
},
{
"value": "inline",
"label": "通栏搜索条(banner 下方白色大搜索条)"
},
{
"value": "overlay",
"label": "全屏搜索浮层(右上放大镜唤起)"
}
]
}
]
}
@@ -34,7 +34,7 @@ class PortalPageRendererTest {
// 再用真实路径就让 .exists() 变真,用例守的"缺模板整页回退"语义就丢了。
when(registry.resource("scholar-blue", "service.html"))
.thenReturn(new ClassPathResource("templates/themes/scholar-blue/__absent__.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
.isEqualTo(ThemeRegistry.DEFAULT_THEME);
@@ -46,7 +46,7 @@ class PortalPageRendererTest {
ThemeRegistry registry = mock(ThemeRegistry.class);
when(registry.resource("scholar-blue", "service.html"))
.thenReturn(new ClassPathResource("templates/themes/scholar-blue/home.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("scholar-blue"), "service.html"))
.isEqualTo("scholar-blue");
@@ -58,7 +58,7 @@ class PortalPageRendererTest {
ThemeRegistry registry = mock(ThemeRegistry.class);
when(registry.resource("default", "guide.html"))
.thenReturn(new ClassPathResource("templates/themes/default/__none__.html"));
PortalPageRenderer renderer = new PortalPageRenderer(null, registry);
PortalPageRenderer renderer = new PortalPageRenderer(null, registry, null);
assertThat(renderer.resolveEffectiveTheme(siteOf("default"), "guide.html"))
.isEqualTo(ThemeRegistry.DEFAULT_THEME);
@@ -0,0 +1,199 @@
package com.yunhai.cms.portal;
import com.yunhai.cms.entity.CmsBanner;
import com.yunhai.cms.entity.CmsPerson;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.repository.CmsPersonRepository;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* docs/20 D 组件库 SSR 契约(campus-glow 皮肤 = 白名单外新类型落 common 骨架兜底):
* D-1 stats_band(count-up 数据位)/ D-2 quick_links 图标宫格 + 首字兜底 / D-3 featured_topics(topic 位)
* / D-5 video_tour / D-6 events(event 位 + 大日期块)/ D-7 people(cms_person)
* / D-8 gallery masonry / D-9 badges。
* 空数据区块整段不渲染(R-44);动效/预览脚本随布局模式注入(R-41/R-42)。
*/
@DisplayName("Portal D-Components - 首页新组件渲染(docs/20 D-1~D-9)")
class PortalDComponentsIT extends CmsIntegrationTestBase {
@Autowired
private MockMvc mockMvc;
@Autowired
private CmsPersonRepository personRepository;
private Integer siteId;
private Integer catId;
@BeforeEach
void seed() {
purgeTables();
CmsSite site = persistSite("dcomp", 1, "组件测试门户");
site.setTheme("campus-glow");
siteId = site.getId();
catId = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId();
persistArticle(siteId, catId, "时讯文章一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60));
// D-3/D-6:banner topic/event 位(扩展列)
CmsBanner topic = persistBanner(siteId, "topic", "2026 招生专题", null, null);
topic.setDescription("欢迎报考本站");
topic.setCtaText("进入专题");
topic.setCtaUrl("https://zs.example.edu.cn");
bannerRepository.saveAndFlush(topic);
CmsBanner event = persistBanner(siteId, "event", "院士讲坛", null, null);
event.setEventDate(LocalDateTime.of(2026, 10, 15, 14, 0));
event.setLocation("图书馆报告厅");
bannerRepository.saveAndFlush(event);
// D-7:人物(有头像 + 无头像首字兜底)
CmsPerson p1 = new CmsPerson();
p1.setSiteId(siteId);
p1.setName("王立群");
p1.setTitle("教授");
p1.setBio("古典文学研究专家");
p1.setAvatar("/upload/cms/avatar-wang.png");
p1.setSortNo(1);
personRepository.saveAndFlush(p1);
CmsPerson p2 = new CmsPerson();
p2.setSiteId(siteId);
p2.setName("李晓");
p2.setTitle("长江学者");
p2.setSortNo(2);
personRepository.saveAndFlush(p2);
clearCaches();
}
@AfterEach
void clean() {
purgeTables();
}
private String body(MvcResult result) throws java.io.UnsupportedEncodingException {
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
}
private void setConfig(String configJson) {
siteRepository.findByCode("dcomp").ifPresent(s -> {
s.setConfigJson(configJson);
siteRepository.saveAndFlush(s);
});
clearCaches();
}
private String home() throws Exception {
return body(mockMvc.perform(get("/portal/dcomp/")).andExpect(status().isOk()).andReturn());
}
@Test
@DisplayName("D-1/2/5/7/8/9:骨架兜底渲染齐备(count-up 数据位/图标宫格/视频封面/人物卡/masonry/徽章)+ 动效脚本注入")
void skeleton_rendersAllNewComponents() throws Exception {
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"title\":\"数说学校\",\"stats\":["
+ "{\"label\":\"在校生\",\"value\":\"3.2\",\"unit\":\"万\"},"
+ "{\"label\":\"院士\",\"value\":\"12\"}]},"
+ "{\"id\":\"q1\",\"type\":\"quick_links\",\"items\":["
+ "{\"name\":\"办事大厅\",\"url\":\"/service\",\"icon\":\"banshi\"},"
+ "{\"name\":\"图书馆\",\"url\":\"https://lib.example.edu.cn\"}]},"
+ "{\"id\":\"v1\",\"type\":\"video_tour\",\"title\":\"云端校园\",\"cover\":\"/upload/cms/vr-cover.png\",\"url\":\"https://vr.example.edu.cn\"},"
+ "{\"id\":\"p1\",\"type\":\"people\",\"title\":\"名师风采\"},"
+ "{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"masonry\"},"
+ "{\"id\":\"b1\",\"type\":\"badges\",\"badgeItems\":[{\"label\":\"软科排名\",\"desc\":\"前100强\",\"image\":\"/upload/cms/badge.png\"}]}"
+ "]}}");
String html = home();
// D-1:计数带 + count-up 数据位
assertThat(html).contains("数说学校").contains("在校生")
.contains("data-plb-countup=\"3.2\"").contains("院士");
// D-2:图标宫格——选中图标渲染 SVG(icon fragment),未选走首字徽章(图名文本存在)
assertThat(html).contains("办事大厅").contains("plb-qicon");
assertThat(html).contains("图书馆");
// D-5:视频封面 + 播放按钮
assertThat(html).contains("plb-video").contains("vr.example.edu.cn");
// D-7:人物(有头像 img + 无头像首字「李」徽章文本)
assertThat(html).contains("王立群").contains("古典文学研究专家").contains("李晓");
// D-8:masonry 布局类
assertThat(html).contains("masonry");
// D-9:徽章
assertThat(html).contains("软科排名").contains("前100强");
// R-42:骨架共享动效脚本注入
assertThat(html).contains("__cmsMotionBound");
// R-41:预览监听脚本注入
assertThat(html).contains("__cmsLayoutPreviewBound");
// F2:区块 id 锚点
assertThat(html).contains("data-plb-id=\"s1\"");
}
@Test
@DisplayName("D-3/D-6:topic/event 位数据渲染(专题描述/CTA + 大日期块/地点)")
void topicAndEvents_renderFromBannerPositions() throws Exception {
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"t1\",\"type\":\"featured_topics\",\"title\":\"专题推荐\"},"
+ "{\"id\":\"e1\",\"type\":\"events\",\"title\":\"学术活动\"}"
+ "]}}");
String html = home();
// D-3:专题卡(banner topic 位扩展列)
assertThat(html).contains("2026 招生专题").contains("欢迎报考本站")
.contains("进入专题").contains("zs.example.edu.cn");
// D-6:活动(banner event 位 + event_date/location)
assertThat(html).contains("院士讲坛").contains("图书馆报告厅").contains("15");
}
@Test
@DisplayName("R-44:空数据区块整段不渲染(无 stats/people/badges 数据 = 无对应 markup,不留空白占位)")
void emptyData_blocksNotRendered() throws Exception {
// people 用真实但无人物挂载的栏目构造空态(树外 id 会被防注入守卫忽略=显示全部)
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"title\":\"数说学校\"}," // 未配 stats
+ "{\"id\":\"p1\",\"type\":\"people\",\"categoryId\":" + catId + "},"
+ "{\"id\":\"x1\",\"type\":\"badges\",\"badgeItems\":[]}" // 空徽章
+ "]}}");
String html = home();
assertThat(html).doesNotContain("数说学校"); // stats 空 → 整块不渲染
assertThat(html).doesNotContain("王立群"); // people 过滤无命中 → 整块不渲染
assertThat(html).doesNotContain("<ul class=\"plb-badges\">"); // badges 空 → 无 ul 运行时 markup(CSS 类名常驻页面)
}
@Test
@DisplayName("D-6:活动按 event_date 升序(未来最近的在前),无日期排后")
void events_sortedByDateAsc() throws Exception {
CmsBanner later = persistBanner(siteId, "event", "更晚讲坛", null, null);
later.setEventDate(LocalDateTime.of(2026, 11, 1, 9, 0));
bannerRepository.saveAndFlush(later);
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"e1\",\"type\":\"events\"}]}}");
String html = home();
int i1 = html.indexOf("院士讲坛");
int i2 = html.indexOf("更晚讲坛");
assertThat(i1).isGreaterThan(0);
assertThat(i2).isGreaterThan(i1); // 10-15 早于 11-01 → 在前
}
@Test
@DisplayName("D-2 主题形状参数:iconShape=hexagon 时图标宫格挂六边形类")
void iconShape_hexagonApplied() throws Exception {
// 主题参数为包级(theme.json default),此处经 .meta 覆盖层不可用(内置包)——
// 改测渲染端对 themeParams 的消费:campus-glow theme.json 默认 circle → plb-shape-circle
setConfig("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"q1\",\"type\":\"quick_links\",\"items\":[{\"name\":\"办事大厅\",\"url\":\"/service\",\"icon\":\"banshi\"}]}"
+ "]}}");
String html = home();
assertThat(html).contains("plb-shape-circle");
}
}
@@ -182,7 +182,7 @@ class PortalHomeLayoutIT extends CmsIntegrationTestBase {
String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn());
assertThat(html).contains("plb-quick-links").contains("快捷通道");
assertThat(count(html, "<a class=\"plb-quick-link\"")).as("4 项中 2 项地址非法 → 丢弃后渲染 2 项").isEqualTo(2);
assertThat(count(html, "class=\"plb-qitem ")).as("4 项中 2 项地址非法 → 丢弃后渲染 2 项(D-2 图标宫格 markup)").isEqualTo(2);
assertThat(html).contains("href=\"https://bsch.edu.cn/hall\"").contains("办事大厅");
assertThat(html).contains("href=\"/library\"").contains("图书馆");
assertThat(html).as("脏数据不得出现在页面").doesNotContain("内联脚本入口").doesNotContain("缺地址入口")
@@ -71,6 +71,20 @@ class CategoryServiceIT extends CmsIntegrationTestBase {
.findFirst().orElseThrow(IllegalStateException::new).getHomeStyle()).isEqualTo("news_grid");
}
@Test
@DisplayName("docs/20 F3:编辑不带 homeStyle 时存量值保留(入口收敛到布局编辑器后不清数据)")
void save_homeStylePreservedWhenAbsent() {
CategorySaveRequest grid = req("形态栏目", 0, 1);
grid.setHomeStyle("news_grid");
CategoryTreeNode created = categoryService.save(TestUsers.superAdmin(), grid);
// 栏目管理已不再提交 homeStyle(F3 唯一入口=首页布局编辑器):再次保存仅改名称
CategorySaveRequest edit = req("形态栏目改名", 0, 1);
edit.setId(created.getId());
CategoryTreeNode updated = categoryService.save(TestUsers.superAdmin(), edit);
assertThat(updated.getHomeStyle()).isEqualTo("news_grid");
}
@Test
@DisplayName("docs/12:homeStyle 非法值被白名单拒绝")
void save_homeStyleInvalidRejected() {
@@ -0,0 +1,110 @@
package com.yunhai.cms.service.impl;
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.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 名师风采人物管理(docs/20 D-7):CRUD + 站点隔离 + 所属栏目防跨站注入。
*/
@DisplayName("PersonService - 名师风采人物管理(docs/20 D-7)")
@Transactional
class PersonServiceIT extends CmsIntegrationTestBase {
@Autowired
private PersonService personService;
private Integer siteId;
private Integer otherSiteId;
@BeforeEach
void seed() {
// @Transactional 回滚隔离不覆盖历史残留(PortalDComponentsIT 等非事务用例的已提交数据),先清表
purgeTables();
siteId = persistSite("psite", 1, "人物站点").getId();
otherSiteId = persistSite("psite2", 1, "另一站点").getId();
}
private static DeleteRequest deleteReq(Integer id, Integer siteId) {
DeleteRequest d = new DeleteRequest();
d.setId(id);
d.setSiteId(siteId);
return d;
}
private PersonSaveRequest req(String name) {
PersonSaveRequest r = new PersonSaveRequest();
r.setSiteId(siteId);
r.setName(name);
r.setSortNo(0);
return r;
}
@Test
@DisplayName("CRUD:保存回显、列表按排序、删除后不可见")
void crud_roundtrip() {
PersonSaveRequest p1 = req("王立群");
p1.setTitle("教授");
p1.setBio("古典文学研究专家");
p1.setAvatar("/cms/upload/wang.png");
p1.setSortNo(2);
PersonVo v1 = personService.save(TestUsers.superAdmin(), p1);
assertThat(v1.getId()).isNotNull();
assertThat(v1.getTitle()).isEqualTo("教授");
PersonSaveRequest p2 = req("李晓");
p2.setSortNo(1);
personService.save(TestUsers.superAdmin(), p2);
List<PersonVo> list = personService.list(TestUsers.superAdmin(), siteId);
assertThat(list).extracting(PersonVo::getName).containsExactly("李晓", "王立群"); // sortNo 升序
personService.delete(TestUsers.superAdmin(), deleteReq(v1.getId(), siteId));
assertThat(personService.list(TestUsers.superAdmin(), siteId)).hasSize(1);
}
@Test
@DisplayName("站点隔离:他站 id 编辑/删除被拒(delete 校验站点归属)")
void siteIsolation_deleteRejected() {
PersonVo mine = personService.save(TestUsers.superAdmin(), req("本站人物"));
// 他站用户视角:siteId 不符 → 删除拒绝
assertThatThrownBy(() -> personService.delete(TestUsers.superAdmin(), deleteReq(mine.getId(), otherSiteId)))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("所属栏目防跨站注入:他站栏目 id 被拒")
void categoryId_crossSiteRejected() {
Integer otherCatId = persistCategory(otherSiteId, 0, "他站栏目",
com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
PersonSaveRequest bad = req("跨站人物");
bad.setCategoryId(otherCatId);
assertThatThrownBy(() -> personService.save(TestUsers.superAdmin(), bad))
.isInstanceOf(BizException.class)
.hasMessageContaining("不属于本站点");
}
@Test
@DisplayName("本站栏目分组:保存成功且回显 categoryId")
void categoryId_ownSiteOk() {
Integer catId = persistCategory(siteId, 0, "名师分组",
com.yunhai.cms.enums.CategoryTypeEnum.LIST).getId();
PersonSaveRequest p = req("分组人物");
p.setCategoryId(catId);
PersonVo v = personService.save(TestUsers.superAdmin(), p);
assertThat(v.getCategoryId()).isEqualTo(catId);
}
}
@@ -236,4 +236,33 @@ class SiteServiceHomeLayoutIT extends CmsIntegrationTestBase {
.isInstanceOf(BizException.class)
.hasMessageContaining(messagePart);
}
// ===== docs/20 D 组件校验 =====
@Test
@DisplayName("docs/20 D:六种新类型合法结构可保存(stats_band/featured_topics/video_tour/events/people/badges)")
void save_dComponentTypesAccepted() {
SiteSaveRequest req = baseReq("layd1");
req.setConfigJson("{\"home_layout\":{\"version\":1,\"blocks\":["
+ "{\"id\":\"s1\",\"type\":\"stats_band\",\"stats\":[{\"label\":\"在校生\",\"value\":\"3.2\",\"unit\":\"万\",\"icon\":\"xiaoli\"}]},"
+ "{\"id\":\"t1\",\"type\":\"featured_topics\",\"items\":[{\"title\":\"招生专题\",\"image\":\"/cms/upload/t.jpg\",\"description\":\"d\",\"ctaText\":\"进入\",\"ctaUrl\":\"https://zs.example.edu.cn\"}]},"
+ "{\"id\":\"v1\",\"type\":\"video_tour\",\"cover\":\"/cms/upload/v.jpg\",\"url\":\"https://vr.example.edu.cn\"},"
+ "{\"id\":\"e1\",\"type\":\"events\"},"
+ "{\"id\":\"p1\",\"type\":\"people\"},"
+ "{\"id\":\"b1\",\"type\":\"badges\",\"badgeItems\":[{\"label\":\"软科\",\"desc\":\"前100\",\"image\":\"/cms/upload/b.png\"}]},"
+ "{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"masonry\"}"
+ "]}}");
SiteVo vo = siteService.save(TestUsers.superAdmin(), req);
assertThat(vo.getId()).isNotNull();
}
@Test
@DisplayName("docs/20 D:各类非法结构被拒(stats 缺失/topics 图非法/video 缺 cover/badges 旧键名/gallery 布局)")
void save_dComponentInvalidRejected() {
CmsSite site = persistSite("layd2x", 1, "组件校验站");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"s1\",\"type\":\"stats_band\"}]}}", "stats");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"t1\",\"type\":\"featured_topics\",\"items\":[{\"title\":\"t\",\"image\":\"javascript:alert(1)\"}]}]}}", "featured_topics");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"v1\",\"type\":\"video_tour\",\"url\":\"https://v.example.edu.cn\"}]}}", "cover");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"b1\",\"type\":\"badges\",\"items\":[{\"label\":\"x\",\"image\":\"/cms/upload/b.png\"}]}]}}", "badges");
assertReject(site.getId(), "{\"home_layout\":{\"version\":1,\"blocks\":[{\"id\":\"g1\",\"type\":\"gallery\",\"layout\":\"zigzag\"}]}}", "grid");
}
}
@@ -51,6 +51,8 @@ public abstract class CmsIntegrationTestBase {
@Autowired
protected CmsLinkRepository linkRepository;
@Autowired
protected com.yunhai.cms.repository.CmsPersonRepository personRepository;
@Autowired
protected CmsAttachmentRepository attachmentRepository;
@BeforeEach
@@ -65,13 +67,14 @@ public abstract class CmsIntegrationTestBase {
}
}
/** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2) */
/** 清空业务表(无外键约束,顺序无碍),非事务用例的隔离手段(含事项两表,事项库 R2;cms_person,docs/20 D-7) */
protected void purgeTables() {
attachmentRepository.deleteAllInBatch();
articleRepository.deleteAllInBatch();
categoryRepository.deleteAllInBatch();
bannerRepository.deleteAllInBatch();
linkRepository.deleteAllInBatch();
personRepository.deleteAllInBatch();
siteRepository.deleteAllInBatch();
}