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

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