Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efd3cb30f9 | ||
|
|
4cb435b715 | ||
|
|
089cb54011 | ||
|
|
c60f9dffb7 | ||
|
|
19e9c18c96 | ||
|
|
854204d1c0 | ||
|
|
bc794ccce3 | ||
|
|
82d6ff4602 | ||
|
|
d2df12b91e | ||
|
|
52433f06a2 | ||
|
|
085c284829 | ||
|
|
c44c35bf2a |
@@ -21,9 +21,10 @@ WHERE code = 'demo';
|
||||
UPDATE cms_banner SET status = 0 WHERE id = 5;
|
||||
|
||||
-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生)
|
||||
-- 主题应用:jdx→academic-navy(学府藏蓝)、xxgc→campus-azure(青蓝校园)——两套高校官网风皮肤随种子落地可视验收
|
||||
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
|
||||
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'default', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
|
||||
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'default', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
|
||||
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'academic-navy', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
|
||||
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'campus-azure', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
|
||||
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES
|
||||
(61, 6, 0, '学院概况', 2, NULL, '<p>机电学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW()),
|
||||
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ============================================================
|
||||
-- 皮肤升级脚本:粉/紫两包下线 → 高校官网风两包接管(幂等,存量库执行一次即可)
|
||||
-- 背景:cherry-bloom(樱语粉韵)/ violet-iris(鸢尾紫韵)不符合高校门户视觉,已从模板库删除;
|
||||
-- 存量站点若仍指向这两个主题,ThemeRegistry 匹配不到会静默回落 default(皮肤全丢),
|
||||
-- 故必须把 theme 列改到新包:academic-navy(学府藏蓝)/ campus-azure(青蓝校园)。
|
||||
-- 目标库:存量 bsch_cms(生产/预发);干净环境无需本脚本
|
||||
-- 顺序:备份查询 → 回填 → 校验
|
||||
-- ============================================================
|
||||
|
||||
-- 1) 先看受影响站点(人工确认后再执行第 2 步)
|
||||
SELECT id, path, name, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
|
||||
|
||||
-- 2) 回填:粉韵→学府藏蓝,紫韵→青蓝校园(仅命中旧值,重复执行无副作用)
|
||||
UPDATE cms_site SET theme = 'academic-navy' WHERE theme = 'cherry-bloom';
|
||||
UPDATE cms_site SET theme = 'campus-azure' WHERE theme = 'violet-iris';
|
||||
|
||||
-- 3) 校验(应返回 0 行)
|
||||
SELECT id, path, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
|
||||
|
||||
-- 备注:站点快照走 Caffeine CacheNames.SITE,TTL 30 分钟(±60s 抖动,见 CacheConfig),
|
||||
-- 实测改库后 400s 门户仍是旧皮肤——改库后请重启服务,或在管理端重新保存一次站点(换肤会主动清缓存)。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -69,13 +69,14 @@ public class ThemeController {
|
||||
}
|
||||
|
||||
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
|
||||
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */
|
||||
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效。
|
||||
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
|
||||
@PutMapping("/{name}/meta")
|
||||
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
|
||||
@PathVariable("name") String name,
|
||||
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
|
||||
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
|
||||
req.getTitle(), req.getDescription(), req.getParams());
|
||||
req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
|
||||
return themeService.voOf(info);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
||||
@@ -41,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
|
||||
@@ -288,6 +291,8 @@ public class PortalPageRenderer {
|
||||
private static final int LAYOUT_SIZE_MAX = 20;
|
||||
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
|
||||
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
|
||||
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
|
||||
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
|
||||
|
||||
/**
|
||||
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
|
||||
@@ -341,20 +346,46 @@ public class PortalPageRenderer {
|
||||
return buildGalleryBlock(site, spec, url);
|
||||
case "links":
|
||||
return buildLinksBlock(site, spec);
|
||||
case "quick_links":
|
||||
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);
|
||||
@@ -443,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;
|
||||
}
|
||||
@@ -463,8 +498,261 @@ public class PortalPageRenderer {
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷入口宫格(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<>();
|
||||
Object raw = spec.get("items");
|
||||
if (raw instanceof List) {
|
||||
for (Object o : (List<?>) raw) {
|
||||
if (!(o instanceof Map)) {
|
||||
continue;
|
||||
}
|
||||
Map<?, ?> item = (Map<?, ?>) o;
|
||||
String name = str(item.get("name"), "").trim();
|
||||
String linkUrl = str(item.get("url"), "").trim();
|
||||
if (name.isEmpty() || !LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
Map<String, Object> block = baseBlock(spec, "quick_links", "快捷通道");
|
||||
block.put("quickLinks", links);
|
||||
return block;
|
||||
}
|
||||
|
||||
/** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */
|
||||
private Map<String, Object> buildNoticeBarBlock(Map<String, Object> spec) {
|
||||
String linkUrl = str(spec.get("url"), "").trim();
|
||||
if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
|
||||
linkUrl = "";
|
||||
}
|
||||
Map<String, Object> block = baseBlock(spec, "notice_bar", "公告");
|
||||
block.put("noticeText", str(spec.get("text"), "").trim());
|
||||
block.put("noticeUrl", linkUrl);
|
||||
block.put("noticeLevel", "warn".equals(str(spec.get("level"), "").trim()) ? "warn" : "info");
|
||||
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;
|
||||
}
|
||||
@@ -44,9 +44,12 @@ public class SiteSaveRequest {
|
||||
private String configJson;
|
||||
|
||||
/**
|
||||
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key;
|
||||
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。
|
||||
* 主题微调参数(方案 §5.3)。
|
||||
*
|
||||
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
|
||||
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
|
||||
*/
|
||||
@Deprecated
|
||||
private Map<String, String> themeParams;
|
||||
|
||||
/** 0=停用 1=启用 */
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.cms.theme.ThemeParam;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
|
||||
@Data
|
||||
@@ -16,4 +17,10 @@ public class ThemeMetaRequest {
|
||||
|
||||
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
|
||||
private List<ThemeParam> params;
|
||||
|
||||
/**
|
||||
* 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值);
|
||||
* 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。
|
||||
*/
|
||||
private Map<String, String> paramValues;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.yunhai.cms.theme.ThemeParam;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 主题包 VO(管理端主题列表,方案 §5.2)。
|
||||
@@ -30,6 +31,12 @@ public class ThemeVo {
|
||||
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
||||
private List<ThemeParamVo> params;
|
||||
|
||||
/**
|
||||
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
|
||||
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
|
||||
*/
|
||||
private Map<String, String> paramValues;
|
||||
|
||||
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
|
||||
private String source;
|
||||
|
||||
@@ -82,6 +89,8 @@ public class ThemeVo {
|
||||
for (ThemeParam p : info.getParams()) {
|
||||
vo.getParams().add(ThemeParamVo.of(p));
|
||||
}
|
||||
vo.setParamValues(info.getParamValues() == null
|
||||
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -289,9 +289,12 @@ public class PortalQueryService {
|
||||
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
|
||||
}
|
||||
|
||||
/** config_json.theme_params 原始值(非对象形态按无参数处理) */
|
||||
/**
|
||||
* 参数原始值(工作项 T:参数微调上移为皮肤包级):取当前主题的包级 {@code paramValues}
|
||||
* ({@code themes/.meta/{name}.json} 覆盖层)。站点 {@code config_json.theme_params} 已退役、不再读取
|
||||
* (存量残留由 SiteService 的 RESERVED 键容忍,不影响门户)。
|
||||
*/
|
||||
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
|
||||
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
|
||||
return raw instanceof Map ? (Map<?, ?>) raw : null;
|
||||
return themeRegistry.paramValuesOf(snapshot.getTheme());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,39 @@ 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)结构约束:类型白名单与规模上限(与前端 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")));
|
||||
"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;
|
||||
private static final int HOME_LAYOUT_SIZE_MIN = 1;
|
||||
private static final int HOME_LAYOUT_SIZE_MAX = 20;
|
||||
/** quick_links(§2.8 类型 A):内联入口宫格上限与单项 name 长度 */
|
||||
private static final int HOME_LAYOUT_MAX_QUICK_LINKS = 12;
|
||||
private static final int HOME_LAYOUT_MAX_LINK_NAME = 20;
|
||||
/** notice_bar(§2.8 类型 B):公告正文上限 */
|
||||
private static final int HOME_LAYOUT_MAX_NOTICE_TEXT = 200;
|
||||
/** 区块内手填链接:仅放行 http(s) 绝对地址或站内根相对路径(拒绝 javascript: 等伪协议) */
|
||||
private static final Pattern HOME_LAYOUT_LINK_PATTERN = Pattern.compile("^(https?://\\S+|/.+)$");
|
||||
/** 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;
|
||||
@@ -174,7 +198,9 @@ public class SiteService {
|
||||
}
|
||||
site.setTheme(resolvedTheme);
|
||||
validateConfigJson(req.getConfigJson(), site.getId());
|
||||
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
|
||||
// 工作项 T:参数微调上移为皮肤包级,站点不再持有参数值——themeParams 入参直接忽略
|
||||
// (config_json 原样入库;theme_params 仍是 RESERVED 键以容忍存量/老前端提交,不报错)
|
||||
site.setConfigJson(req.getConfigJson());
|
||||
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||
site = siteRepository.save(site);
|
||||
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
|
||||
@@ -240,29 +266,6 @@ public class SiteService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
|
||||
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
|
||||
* SPA 不再手拼 configJson 中的该节点。
|
||||
*/
|
||||
private String mergeThemeParams(SiteSaveRequest req, String theme) {
|
||||
if (req.getThemeParams() == null) {
|
||||
return req.getConfigJson();
|
||||
}
|
||||
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
|
||||
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
|
||||
if (filtered.isEmpty()) {
|
||||
config.remove("theme_params");
|
||||
} else {
|
||||
config.put("theme_params", filtered);
|
||||
}
|
||||
try {
|
||||
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
|
||||
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
|
||||
@@ -381,7 +384,8 @@ public class SiteService {
|
||||
|
||||
/**
|
||||
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
|
||||
* type 白名单(article_list/ticker/gallery/links)、size 1~20、字符串字段(title/subtitle)≤100;
|
||||
* type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、字符串字段(title/subtitle)≤100;
|
||||
* quick_links / notice_bar 另有分支校验(见 {@link #validateQuickLinksBlock} / {@link #validateNoticeBarBlock});
|
||||
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
|
||||
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
|
||||
*/
|
||||
@@ -411,6 +415,25 @@ public class SiteService {
|
||||
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
|
||||
}
|
||||
if ("quick_links".equals(type.asText())) {
|
||||
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
|
||||
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
|
||||
@@ -434,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) {
|
||||
@@ -452,6 +483,181 @@ public class SiteService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links 区块必须包含 items(入口列表)");
|
||||
}
|
||||
if (!items.isArray()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 必须是数组");
|
||||
}
|
||||
if (items.size() == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 至少 1 项");
|
||||
}
|
||||
if (items.size() > HOME_LAYOUT_MAX_QUICK_LINKS) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"quick_links items 不能超过 " + HOME_LAYOUT_MAX_QUICK_LINKS + " 项");
|
||||
}
|
||||
for (JsonNode item : items) {
|
||||
if (!item.isObject()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 每项必须是对象({name, url})");
|
||||
}
|
||||
JsonNode name = item.get("name");
|
||||
if (name == null || !name.isTextual() || name.asText().trim().isEmpty()
|
||||
|| name.asText().trim().length() > HOME_LAYOUT_MAX_LINK_NAME) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** notice_bar(v2 手册 §2.8 类型 B):text 必填 ≤200 字;url 可选;level 可选且限 info/warn */
|
||||
private void validateNoticeBarBlock(JsonNode block) {
|
||||
JsonNode text = block.get("text");
|
||||
if (text == null || !text.isTextual() || text.asText().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar 区块必须填写 text(公告正文)");
|
||||
}
|
||||
if (text.asText().length() > HOME_LAYOUT_MAX_NOTICE_TEXT) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"notice_bar text 不能超过 " + HOME_LAYOUT_MAX_NOTICE_TEXT + " 字");
|
||||
}
|
||||
if (block.has("url")) {
|
||||
requireLayoutLink(block.get("url"), "notice_bar");
|
||||
}
|
||||
JsonNode level = block.get("level");
|
||||
if (level != null && (!level.isTextual() || !HOME_LAYOUT_NOTICE_LEVELS.contains(level.asText()))) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar level 只能是 info 或 warn");
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块内手填链接统一校验(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,
|
||||
what + " url 须为 http(s):// 地址或站内路径(/ 开头)");
|
||||
}
|
||||
}
|
||||
|
||||
/** 区块内图片资产校验(站点上传路径 / 开头或 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}(外链样式注入)、
|
||||
|
||||
@@ -36,6 +36,13 @@ public class ThemeInfo {
|
||||
|
||||
private List<ThemeParam> params = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 包级参数值(工作项 T:参数微调从站点级上移为皮肤包级)。
|
||||
* 值不写 jar 内 theme.json,一律落 {@code {cms.theme.dir}/.meta/{name}.json} 覆盖层,
|
||||
* 由 {@link ThemeRegistry#applyMetaOverride} 在装载时叠加;缺项回落 params[].defaultValue。
|
||||
*/
|
||||
private Map<String, String> paramValues = new LinkedHashMap<>();
|
||||
|
||||
public String screenshotPath() {
|
||||
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
@@ -306,6 +307,7 @@ public class ThemePackageService {
|
||||
root.put("description", description);
|
||||
}
|
||||
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
copyMetaParamValues(sourceName, newName);
|
||||
themeRegistry.reload();
|
||||
publishThemeChanged();
|
||||
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
|
||||
@@ -318,6 +320,28 @@ public class ThemePackageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生时复制源包的 paramValues(v1 §3.4):参数值存 <code>.meta/{name}.json</code> 覆盖层、
|
||||
* 不在包目录内,复制包体天然带不过去 → 不显式带则新包回落 theme.json 默认值,
|
||||
* "复制包做差异化" 就得重调一遍参数。仅复制 paramValues 节点(title/description 由派生表单写入新包 theme.json)。
|
||||
*/
|
||||
private void copyMetaParamValues(String sourceName, String newName) throws IOException {
|
||||
Path source = Paths.get(themeDir, ".meta", sourceName + ".json");
|
||||
if (!Files.isRegularFile(source)) {
|
||||
return;
|
||||
}
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
com.fasterxml.jackson.databind.JsonNode values = mapper.readTree(source.toFile()).path("paramValues");
|
||||
if (!values.isObject() || values.size() == 0) {
|
||||
return;
|
||||
}
|
||||
Path target = Paths.get(themeDir, ".meta", newName + ".json");
|
||||
Files.createDirectories(target.getParent());
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = mapper.createObjectNode();
|
||||
root.set("paramValues", values);
|
||||
Files.write(target, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
}
|
||||
|
||||
/** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */
|
||||
private void copyClasspathPackage(String sourceName, Path target) throws IOException {
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
@@ -354,9 +378,25 @@ public class ThemePackageService {
|
||||
/**
|
||||
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
|
||||
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
|
||||
* 兼容签名:不改包级参数值。
|
||||
*/
|
||||
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
|
||||
List<ThemeParam> params) {
|
||||
return updateMeta(user, name, title, description, params, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据 + 包级参数值编辑(工作项 T:参数微调从站点级迁到皮肤包级)。
|
||||
*
|
||||
* <p>title/description/params 语义与任务四一致(内置包 schema 拒改);{@code paramValues} 则
|
||||
* <b>内置包与外部包统一写 .meta 覆盖层</b>——值不属于包体内容,包体(theme.json + 模板 + 资产)
|
||||
* 保持只读语义不变,「同包不同配色」的差异化仍由复制皮肤包承担。
|
||||
*
|
||||
* @param paramValues null=不改;非 null=整体替换(空 Map=清空、恢复 theme.json 默认值);
|
||||
* 未声明 key 与非法值按包 schema 丢弃(同批改 schema 时以新 schema 为准)
|
||||
*/
|
||||
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
|
||||
List<ThemeParam> params, Map<String, String> paramValues) {
|
||||
requireSuperAdmin(user);
|
||||
if (!themeRegistry.contains(name)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
|
||||
@@ -365,6 +405,15 @@ public class ThemePackageService {
|
||||
if (!external && params != null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)");
|
||||
}
|
||||
Map<String, String> filteredValues = null;
|
||||
if (paramValues != null) {
|
||||
ThemeInfo schema = themeRegistry.get(name);
|
||||
if (external && params != null) {
|
||||
schema = new ThemeInfo();
|
||||
schema.setParams(params);
|
||||
}
|
||||
filteredValues = schema.filterValidValues(paramValues);
|
||||
}
|
||||
try {
|
||||
if (external) {
|
||||
Path json = Paths.get(themeDir, name, "theme.json");
|
||||
@@ -388,8 +437,11 @@ public class ThemePackageService {
|
||||
root.set("params", mapper.valueToTree(params));
|
||||
}
|
||||
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
if (filteredValues != null) {
|
||||
writeMetaOverride(name, null, null, filteredValues);
|
||||
}
|
||||
} else {
|
||||
writeBuiltinMetaOverride(name, title, description);
|
||||
writeMetaOverride(name, title, description, filteredValues);
|
||||
}
|
||||
themeRegistry.reload();
|
||||
publishThemeChanged();
|
||||
@@ -402,12 +454,16 @@ public class ThemePackageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认),
|
||||
* 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。
|
||||
* .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
|
||||
* 三者全空且未提交 paramValues(即任务四的"恢复默认"手势)→ 删除覆盖层;
|
||||
* 显式提交空 paramValues 只摘掉 paramValues 节点,保留覆盖层里既有的 title/description。
|
||||
*/
|
||||
private void writeBuiltinMetaOverride(String name, String title, String description) throws IOException {
|
||||
private void writeMetaOverride(String name, String title, String description,
|
||||
Map<String, String> paramValues) throws IOException {
|
||||
Path overlay = Paths.get(themeDir, ".meta", name + ".json");
|
||||
if (!StringUtils.hasText(title) && (description == null || description.trim().isEmpty())) {
|
||||
boolean noTitle = !StringUtils.hasText(title);
|
||||
boolean noDesc = description == null || description.trim().isEmpty();
|
||||
if (noTitle && noDesc && paramValues == null) {
|
||||
try {
|
||||
Files.deleteIfExists(overlay);
|
||||
} catch (IOException e) {
|
||||
@@ -433,6 +489,13 @@ public class ThemePackageService {
|
||||
if (description != null) {
|
||||
root.put("description", description);
|
||||
}
|
||||
if (paramValues != null) {
|
||||
if (paramValues.isEmpty()) {
|
||||
root.remove("paramValues");
|
||||
} else {
|
||||
root.set("paramValues", mapper.valueToTree(paramValues));
|
||||
}
|
||||
}
|
||||
Files.createDirectories(overlay.getParent());
|
||||
Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||
}
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
package com.yunhai.cms.theme;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用,
|
||||
* 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移:
|
||||
* specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、
|
||||
* fragments/head.html、param 规范、screenshot 存在。
|
||||
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。
|
||||
* fragments/head.html、首页区块引用一致性(§2.5 坑⑤)、param 规范、screenshot 存在。
|
||||
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1);
|
||||
* readFn 读资源文本(UTF-8),读不到返回 null(不臆断,交由存在性校验与模板引擎兜底)。
|
||||
*/
|
||||
public final class ThemePackageValidator {
|
||||
|
||||
/** 本包首页区块模板(自研形态承载体) */
|
||||
private static final String HOME_BLOCKS = "fragments/home-blocks.html";
|
||||
|
||||
/** Thymeleaf 模板引用对:~{<路径表达式> :: <fragment>( */
|
||||
private static final Pattern TEMPLATE_REF =
|
||||
Pattern.compile("~\\{([\\s\\S]*?)\\s*::\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\(");
|
||||
|
||||
/** fragment 定义:th:fragment="name(...)"(空格/逗号分隔的多签名一律取标识符) */
|
||||
private static final Pattern FRAGMENT_DEF = Pattern.compile("th:fragment\\s*=\\s*\"([^\"]*)\"");
|
||||
|
||||
private ThemePackageValidator() {
|
||||
}
|
||||
|
||||
/** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */
|
||||
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn) {
|
||||
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn,
|
||||
Function<String, String> readFn) {
|
||||
if (info == null) {
|
||||
throw new IllegalArgumentException("清单为空");
|
||||
}
|
||||
@@ -38,6 +56,7 @@ public final class ThemePackageValidator {
|
||||
if (!existsFn.test("fragments/head.html")) {
|
||||
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
|
||||
}
|
||||
validateHomeBlockFragmentRefs(dirName, readFn);
|
||||
validateParams(info);
|
||||
String shot = info.screenshotPath();
|
||||
if (!existsFn.test(shot)) {
|
||||
@@ -72,4 +91,75 @@ public final class ThemePackageValidator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页区块门禁(v2 手册 §2.5 坑⑤):home-blocks **不是**包的必需项——B 档包整段走
|
||||
* themes/common 兜底骨架属正常形态,强制"8 形态齐全"会把它们剔除并静默回落 default(重大事故)。
|
||||
* 只校验**引用一致性**:home.html 每把一种形态分发给本包 fragments/home-blocks
|
||||
* (${themeBase} 拼接或 themes/<目录名>/ 字面两种写法),该文件就必须存在并定义被引用的
|
||||
* fragment,否则门户首页渲染整页 500。缺项清单写进异常消息,由 Registry 剔除分支 log.warn 带出。
|
||||
*/
|
||||
static void validateHomeBlockFragmentRefs(String dirName, Function<String, String> readFn) {
|
||||
String home = readFn.apply("home.html");
|
||||
if (home == null || home.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> referenced = ownHomeBlockRefs(home, dirName);
|
||||
if (referenced.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String blocks = readFn.apply(HOME_BLOCKS);
|
||||
if (blocks == null) {
|
||||
throw new IllegalArgumentException("home.html 把区块分发给本包 " + HOME_BLOCKS
|
||||
+ ",但该文件不存在(引用形态:" + join(referenced) + ")");
|
||||
}
|
||||
Set<String> defined = fragmentNamesOf(blocks);
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String name : referenced) {
|
||||
if (!defined.contains(name)) {
|
||||
missing.add(name);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalArgumentException("本包 " + HOME_BLOCKS + " 缺 fragment 定义:" + join(missing)
|
||||
+ "(home.html 已引用这些形态,门户首页渲染会整页 500)");
|
||||
}
|
||||
}
|
||||
|
||||
/** home.html 中指向**本包** home-blocks 的 fragment 名(按出现顺序去重;common 兜底引用不计入) */
|
||||
static List<String> ownHomeBlockRefs(String homeHtml, String dirName) {
|
||||
List<String> names = new ArrayList<>();
|
||||
Matcher matcher = TEMPLATE_REF.matcher(homeHtml);
|
||||
while (matcher.find()) {
|
||||
String target = matcher.group(1);
|
||||
if (!target.contains("/fragments/home-blocks") || target.contains("themes/common/")) {
|
||||
continue;
|
||||
}
|
||||
boolean ownPackage = target.contains("themeBase") || target.contains("themes/" + dirName + "/");
|
||||
String fragment = matcher.group(2);
|
||||
if (ownPackage && !names.contains(fragment)) {
|
||||
names.add(fragment);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** 模板文本内声明的全部 fragment 名 */
|
||||
static Set<String> fragmentNamesOf(String templateText) {
|
||||
Set<String> names = new HashSet<>();
|
||||
Matcher matcher = FRAGMENT_DEF.matcher(templateText);
|
||||
while (matcher.find()) {
|
||||
for (String signature : matcher.group(1).trim().split("[\\s,]+")) {
|
||||
String name = signature.split("\\(")[0].trim();
|
||||
if (!name.isEmpty()) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String join(List<String> names) {
|
||||
return String.join("、", names);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -113,8 +116,9 @@ public class ThemeRegistry {
|
||||
String dirName = dirNameOf(resource);
|
||||
try {
|
||||
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
|
||||
ThemePackageValidator.validate(info, dirName, rel ->
|
||||
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists());
|
||||
ThemePackageValidator.validate(info, dirName,
|
||||
rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists(),
|
||||
rel -> readClasspath("templates/themes/" + dirName + "/" + rel));
|
||||
applyMetaOverride(info);
|
||||
loaded.put(info.getName(), info);
|
||||
loadedSources.put(info.getName(), SOURCE_BUILTIN);
|
||||
@@ -130,7 +134,9 @@ public class ThemeRegistry {
|
||||
Path json = pkg.resolve("theme.json");
|
||||
try {
|
||||
ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class);
|
||||
ThemePackageValidator.validate(info, dirName, rel -> Files.exists(pkg.resolve(rel)));
|
||||
ThemePackageValidator.validate(info, dirName,
|
||||
rel -> Files.exists(pkg.resolve(rel)),
|
||||
rel -> readTextFile(pkg.resolve(rel)));
|
||||
if (loaded.containsKey(info.getName())) {
|
||||
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
|
||||
continue;
|
||||
@@ -222,6 +228,24 @@ public class ThemeRegistry {
|
||||
return REQUIRED_TEMPLATES;
|
||||
}
|
||||
|
||||
/** 读 classpath 包内文本(UTF-8);读不到返回 null(区块门禁据此跳过,不臆断) */
|
||||
private static String readClasspath(String location) {
|
||||
try (InputStream in = new ClassPathResource(location).getInputStream()) {
|
||||
return StreamUtils.copyToString(in, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读外部包内文本(UTF-8);读不到返回 null */
|
||||
private static String readTextFile(Path file) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 资源定位 =====
|
||||
|
||||
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
|
||||
@@ -235,8 +259,8 @@ public class ThemeRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description——
|
||||
* 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。
|
||||
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
|
||||
* 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
|
||||
*/
|
||||
private void applyMetaOverride(ThemeInfo info) {
|
||||
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
|
||||
@@ -254,11 +278,26 @@ public class ThemeRegistry {
|
||||
if (meta.getDescription() != null) {
|
||||
info.setDescription(meta.getDescription());
|
||||
}
|
||||
if (meta.getParamValues() != null && !meta.getParamValues().isEmpty()) {
|
||||
info.setParamValues(new LinkedHashMap<>(meta.getParamValues()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包级参数值(工作项 T):主题不存在返回空 Map(调用方免判空)。
|
||||
* 返回不可变视图——参数值的唯一写入口是 {@code ThemePackageService.updateMeta}。
|
||||
*/
|
||||
public Map<String, String> paramValuesOf(String name) {
|
||||
ThemeInfo info = name == null ? null : themes.get(name);
|
||||
if (info == null || info.getParamValues() == null) {
|
||||
return java.util.Collections.emptyMap();
|
||||
}
|
||||
return java.util.Collections.unmodifiableMap(info.getParamValues());
|
||||
}
|
||||
|
||||
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
|
||||
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
|
||||
if (themeDir == null || themeDir.isEmpty()) {
|
||||
|
||||
@@ -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)';
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="code">400</div>
|
||||
<h1>请求参数有误</h1>
|
||||
<p>页面地址中的参数不正确(如页码格式错误)。<br>请修正后重试,或返回门户首页继续浏览。</p>
|
||||
<a href="/cms/portal/">返回门户首页</a>
|
||||
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="code">404</div>
|
||||
<h1>页面不存在或已下线</h1>
|
||||
<p>您访问的页面可能已被删除、更改地址或暂时不可用。<br>请检查网址是否正确,或返回门户首页继续浏览。</p>
|
||||
<a href="/cms/portal/">返回门户首页</a>
|
||||
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="code">500</div>
|
||||
<h1>服务暂时不可用</h1>
|
||||
<p>系统开小差了,工程师正在抢修。<br>请稍后刷新重试,或返回门户首页继续浏览。</p>
|
||||
<a href="/cms/portal/">返回门户首页</a>
|
||||
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="code">:(</div>
|
||||
<h1>出错了</h1>
|
||||
<p>请求处理出现异常,请稍后重试。<br>如持续出现,请联系管理员。</p>
|
||||
<a href="/cms/portal/">返回门户首页</a>
|
||||
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# academic-navy(学府藏蓝)
|
||||
|
||||
高校官网风皮肤包:以 `scholar-blue` 为基座派生,配色与首页区块形态按综合类大学官网(浙江理工大学一类)重做。
|
||||
配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——派生包不自带区块形态时,
|
||||
正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
|
||||
|
||||
## 风格定位
|
||||
|
||||
- 主色藏蓝 `#0b4a8f` + 加深 `#063161` + 点缀金 `#b8934a`(标题左竖条与居中线脚走金线,区别于基座的全蓝语言)。
|
||||
- 圆角收紧到 4/8px、页脚 `#0a2b4d`,行高压到 40px 一档,整体比基座更「机关报」而非「校园活动页」。
|
||||
- 全部 11 个颜色参数可在站点 theme_params 微调(`primary/primaryDark/primaryLight/accent/navBg/footerBg/text/textLight/bg/cardBg/border`)。
|
||||
|
||||
## 首页区块形态(布局模式与回落模式同构)
|
||||
|
||||
| homeStyle | 呈现 | 类名 |
|
||||
| -- | -- | -- |
|
||||
| `news_grid` | 双栏要闻:左 3 份图文头条(16:7 封面 + 摘要 + 元信息)、右 2 份紧凑行;区块背景可取站点 `news_bg_image` | `an-split` / `an-feat-*` |
|
||||
| `compact` | 日期方块行:46px 见方的「日 + 年月」方块,hover 反白 | `an-date-chip` |
|
||||
| `media_focus` | 媒体聚焦:来源徽标 + 双列密行 | `an-src` / `an-block-list--two` |
|
||||
| `image_wall` | 四列图片墙:封面 + 底部渐变遮罩标题,hover 抬图 | `an-wall` |
|
||||
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `an-block--flow` |
|
||||
| `ticker` | 藏蓝要闻速递横条(单行不换行、超长省略,中文不塌列) | `an-ticker*` |
|
||||
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `an-gallery` |
|
||||
| `links` | 友情链接分组(胶囊标签行) | `an-links` |
|
||||
|
||||
## 契约要点
|
||||
|
||||
- 结构类前缀 `an-`,CSS 变量前缀 `--an-`;`:root` 把 `--an-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
|
||||
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
|
||||
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
|
||||
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
|
||||
- 回归防线:`AcademicSkinContractTest`(自带区块 fragment + 中文不塌列 + 契约变量别名 + 主色必须学术蓝)、
|
||||
`PortalHomeLayoutIT#academicNavy_layoutUsesOwnFragments`(布局模式不落通用骨架)、`ThemeContractIT`(7 条路由)。
|
||||
|
After Width: | Height: | Size: 721 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<rect width="96" height="96" rx="20" fill="#0a5aa8"/>
|
||||
<path d="M48 16l8.6 17.5 19.3 2.8-14 13.6 3.3 19.2L48 60.1 30.8 69.1l3.3-19.2-14-13.6 19.3-2.8L48 16z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,190 @@
|
||||
/* academic-navy 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。
|
||||
与 default 同源逻辑,无任何外部依赖。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ---------- 顶栏日期(本地时间,YYYY年M月D日 星期X) ---------- */
|
||||
var dateEl = document.getElementById('siteDate');
|
||||
if (dateEl) {
|
||||
var now = new Date();
|
||||
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
|
||||
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
|
||||
+ now.getDate() + '日 星期' + week;
|
||||
}
|
||||
|
||||
/* ---------- 导航吸顶反蓝(桌面端;≤768 不吸顶) ---------- */
|
||||
var nav = document.getElementById('anNav');
|
||||
var DESKTOP_MIN = 769;
|
||||
var navBaseTop = 0;
|
||||
var holder = null;
|
||||
|
||||
function measureNavTop() {
|
||||
unlockNav();
|
||||
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
|
||||
}
|
||||
|
||||
function lockNav() {
|
||||
if (!nav.classList.contains('an-nav--fixed')) {
|
||||
holder = document.createElement('div');
|
||||
holder.className = 'an-nav-holder';
|
||||
holder.style.height = nav.offsetHeight + 'px';
|
||||
nav.parentNode.insertBefore(holder, nav);
|
||||
nav.classList.add('an-nav--fixed');
|
||||
} else if (holder) {
|
||||
holder.style.height = nav.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function unlockNav() {
|
||||
nav.classList.remove('an-nav--fixed');
|
||||
if (holder && holder.parentNode) {
|
||||
holder.parentNode.removeChild(holder);
|
||||
}
|
||||
holder = null;
|
||||
}
|
||||
|
||||
function onScrollNav() {
|
||||
if (window.innerWidth < DESKTOP_MIN) {
|
||||
unlockNav();
|
||||
return;
|
||||
}
|
||||
if (window.pageYOffset > navBaseTop) {
|
||||
lockNav();
|
||||
} else {
|
||||
unlockNav();
|
||||
}
|
||||
}
|
||||
|
||||
if (nav) {
|
||||
measureNavTop();
|
||||
window.addEventListener('scroll', onScrollNav, { passive: true });
|
||||
var resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
if (resizeTimer) { window.clearTimeout(resizeTimer); }
|
||||
resizeTimer = window.setTimeout(function () {
|
||||
measureNavTop();
|
||||
onScrollNav();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 移动端抽屉 + 二级手风琴 ---------- */
|
||||
var navToggle = document.querySelector('.an-nav-toggle');
|
||||
var navList = document.querySelector('.an-nav-list');
|
||||
|
||||
if (navToggle && navList) {
|
||||
navToggle.addEventListener('click', function () {
|
||||
var open = navList.classList.toggle('an-open');
|
||||
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
/* 窄屏下:点击带二级的项先展开面板,再点才跳转;宽屏交还给 CSS hover */
|
||||
if (navList) {
|
||||
navList.addEventListener('click', function (e) {
|
||||
if (window.innerWidth >= DESKTOP_MIN) { return; }
|
||||
var link = e.target.closest ? e.target.closest('a') : null;
|
||||
if (!link) { return; }
|
||||
var li = link.parentElement;
|
||||
if (!li || !li.classList.contains('has-sub')) { return; }
|
||||
var sub = li.querySelector('.an-sub');
|
||||
if (!sub) { return; }
|
||||
if (!li.classList.contains('an-sub-open')) {
|
||||
e.preventDefault();
|
||||
li.classList.add('an-sub-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 首页轮播 ---------- */
|
||||
var root = document.querySelector('.an-banner');
|
||||
var slides = document.querySelectorAll('.an-banner-slide');
|
||||
var dots = document.querySelectorAll('.an-banner-dots button');
|
||||
var prevBtn = document.querySelector('.an-banner-arrow.prev');
|
||||
var nextBtn = document.querySelector('.an-banner-arrow.next');
|
||||
if (!slides.length || !root) {
|
||||
return;
|
||||
}
|
||||
var current = 0;
|
||||
var timer = null;
|
||||
var INTERVAL = 5000;
|
||||
|
||||
function show(index) {
|
||||
slides[current].classList.remove('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.remove('active');
|
||||
}
|
||||
current = (index + slides.length) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if (slides.length > 1) {
|
||||
timer = window.setInterval(function () {
|
||||
show(current + 1);
|
||||
}, INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dots.forEach(function (dot, index) {
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
start();
|
||||
});
|
||||
});
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () {
|
||||
show(current - 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
show(current + 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
root.addEventListener('mouseenter', stop);
|
||||
root.addEventListener('mouseleave', start);
|
||||
root.addEventListener('focusin', stop);
|
||||
root.addEventListener('focusout', start);
|
||||
root.setAttribute('tabindex', '-1');
|
||||
root.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
show(current - 1);
|
||||
start();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
show(current + 1);
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
/* 触摸滑动切页 */
|
||||
var touchX = null;
|
||||
root.addEventListener('touchstart', function (e) {
|
||||
touchX = e.changedTouches[0].clientX;
|
||||
stop();
|
||||
}, { passive: true });
|
||||
root.addEventListener('touchend', function (e) {
|
||||
if (touchX === null) { return; }
|
||||
var dx = e.changedTouches[0].clientX - touchX;
|
||||
if (Math.abs(dx) > 40) {
|
||||
show(dx < 0 ? current + 1 : current - 1);
|
||||
}
|
||||
touchX = null;
|
||||
start();
|
||||
}, { passive: true });
|
||||
|
||||
start();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="an-page an-container" id="main-content">
|
||||
<div class="an-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="an-side-nav-list">
|
||||
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="an-page-card">
|
||||
<nav class="an-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node : ${categoryPath}">
|
||||
<span class="an-crumb-sep" aria-hidden="true">/</span>
|
||||
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||
</th:block>
|
||||
<span class="an-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||
<span class="an-crumb-current">正文</span>
|
||||
</nav>
|
||||
|
||||
<article class="an-article">
|
||||
<h1 class="an-article-title" th:text="${article.title}">文章标题</h1>
|
||||
<div class="an-article-meta">
|
||||
<time th:if="${article.publishTime != null}"
|
||||
th:text="${#temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
|
||||
<span th:if="${!#strings.isEmpty(article.authorName)}"
|
||||
th:text="'作者:' + ${article.authorName}">作者</span>
|
||||
<span th:if="${!#strings.isEmpty(article.source)}"
|
||||
th:text="'来源:' + ${article.source}">来源</span>
|
||||
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
|
||||
</div>
|
||||
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
|
||||
其余字段一律 th:text 转义输出 -->
|
||||
<div class="an-article-content" th:utext="${article.content}">正文</div>
|
||||
</article>
|
||||
|
||||
<nav class="an-sibling" aria-label="上下篇导航">
|
||||
<a th:if="${prevArticle != null}" class="an-sib prev"
|
||||
th:href="${url.article(prevArticle.id)}">
|
||||
<span class="an-sib-label">上一篇</span>
|
||||
<span class="an-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
|
||||
</a>
|
||||
<span class="an-sib-none prev" th:if="${prevArticle == null}">
|
||||
<span class="an-sib-label">上一篇</span>
|
||||
<span class="an-sib-title">已经是最早一篇</span>
|
||||
</span>
|
||||
<a th:if="${nextArticle != null}" class="an-sib next"
|
||||
th:href="${url.article(nextArticle.id)}">
|
||||
<span class="an-sib-label">下一篇</span>
|
||||
<span class="an-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
|
||||
</a>
|
||||
<span class="an-sib-none next" th:if="${nextArticle == null}">
|
||||
<span class="an-sib-label">下一篇</span>
|
||||
<span class="an-sib-title">已是最新一篇</span>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- academic-navy 公共页脚:深蓝渐变底 + 同心圆环纹理(CSS 绘制) + 三栏(品牌/联系备案/友链分组) + 底条校名。
|
||||
数据绑定与 default 契约一致(linkGroups/contact/copyright/beian/logo/schName),仅换皮。 -->
|
||||
<footer class="an-footer" th:fragment="footer">
|
||||
<i class="an-ring an-ring--1" aria-hidden="true"></i>
|
||||
<i class="an-ring an-ring--2" aria-hidden="true"></i>
|
||||
<i class="an-ring an-ring--3" aria-hidden="true"></i>
|
||||
|
||||
<div class="an-footer-inner">
|
||||
<!-- 品牌栏 -->
|
||||
<div class="an-footer-brand">
|
||||
<span class="an-footer-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="an-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<span class="an-footer-badge an-footer-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" focusable="false">
|
||||
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="an-footer-site" th:text="${site.name}">站点名称</span>
|
||||
</div>
|
||||
|
||||
<!-- 联系/版权/备案 -->
|
||||
<div class="an-footer-info">
|
||||
<p class="an-footer-line" th:if="${site.config['contact'] != null}">
|
||||
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
|
||||
</p>
|
||||
<p class="an-footer-line" th:if="${site.config['copyright'] != null}">
|
||||
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
|
||||
</p>
|
||||
<p class="an-footer-line" th:if="${site.config['beian'] != null}">
|
||||
<i class="an-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 友情链接(分组渲染;无分组整栏不渲染) -->
|
||||
<div class="an-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||
<div class="an-links-group" th:each="group : ${site.linkGroups}"
|
||||
th:if="${!#lists.isEmpty(group.links)}">
|
||||
<span class="an-group-name" th:text="${group.groupName}">分组</span>
|
||||
<p class="an-links-row">
|
||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||
<span th:text="${link.name}">链接</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="an-footer-bottom">
|
||||
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
|
||||
th:text="${site.schName}">学校名称</p>
|
||||
</div>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<!-- academic-navy 公共 head 片段:与 default 契约一致(pageTitle/seo/canonical/og/themeVarsCss/favicon 兜底) -->
|
||||
<head th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle}">页面标题</title>
|
||||
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
||||
th:content="${seoDescription}">
|
||||
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
|
||||
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
|
||||
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
|
||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||
<link rel="icon" type="image/png"
|
||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||
</head>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- academic-navy 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
|
||||
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
|
||||
<header class="an-header" th:fragment="header">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="an-topbar">
|
||||
<div class="an-topbar-inner">
|
||||
<span class="an-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
|
||||
<div class="an-topbar-side" th:if="${!site.colleges.isEmpty()}"
|
||||
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
|
||||
<div class="an-colleges">
|
||||
<span class="an-colleges-toggle">
|
||||
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
|
||||
<i class="an-colleges-arrow" aria-hidden="true">▾</i>
|
||||
</span>
|
||||
<ul class="an-colleges-menu">
|
||||
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
|
||||
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
|
||||
th:title="${col.name}" th:text="${col.name}">学院名称</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品牌行 -->
|
||||
<div class="an-brandbar">
|
||||
<div class="an-brandbar-inner">
|
||||
<div class="an-brand">
|
||||
<span class="an-brand-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="an-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<span class="an-brand-badge an-brand-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
|
||||
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<a class="an-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
|
||||
</div>
|
||||
<form class="an-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">
|
||||
<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>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主导航:白底深蓝字;下拉为主蓝半透明面板(.an-sub 展开动效见 main.css);吸顶反蓝由 main.js 处理 -->
|
||||
<nav class="an-nav" id="anNav" aria-label="栏目导航">
|
||||
<div class="an-nav-inner">
|
||||
<button class="an-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<ul class="an-nav-list">
|
||||
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
</li>
|
||||
<li th:each="cat : ${navCategories}"
|
||||
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
|
||||
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${cat.name}">栏目</a>
|
||||
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="an-caret" aria-hidden="true">▾</i></a>
|
||||
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
|
||||
<ul class="an-sub" th:if="${!cat.children.isEmpty()}">
|
||||
<li th:each="sub : ${cat.children}"
|
||||
th:classappend="${sub.id == currentCategoryId} ? 'active'">
|
||||
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${sub.name}">子栏目</a>
|
||||
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
<!--
|
||||
academic-navy(学府藏蓝)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
|
||||
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
|
||||
结构复用主题 main.css 第 7/9 节既有语汇(an-block / an-block-head--bar / an-block-item / an-date-chip /
|
||||
an-feat-* / an-top-badge / an-empty),仅布局模式特有的排布(双栏、速递条、图墙、链接行)另起第 16 节。
|
||||
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background),
|
||||
与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
|
||||
-->
|
||||
<!-- 形态一:双栏要闻(homeStyle=news_grid)——左 3 份头条图文、右 2 份紧凑行(综合类高校官网 60/40) -->
|
||||
<section class="an-section an-container" th:fragment="news_grid(block)" aria-label="栏目区块"
|
||||
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
|
||||
<div class="an-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
|
||||
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
|
||||
<div class="an-block an-block--flow"
|
||||
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
|
||||
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">校园要闻</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<div class="an-split" th:if="${lead != null}">
|
||||
<div class="an-block-featured">
|
||||
<a th:href="${url.article(lead.id)}">
|
||||
<span class="an-feat-cover" th:if="${lead.coverImage != null}">
|
||||
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="an-feat-title" th:text="${lead.title}">头条标题</span>
|
||||
<span class="an-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
|
||||
th:text="${lead.summary}">摘要</span>
|
||||
<span class="an-feat-meta">
|
||||
<span class="an-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="an-block-list" th:if="${rest != null}">
|
||||
<li class="an-block-item" th:each="item : ${rest}">
|
||||
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="an-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态二:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
|
||||
<section class="an-section an-container" th:fragment="card(block)" aria-label="栏目区块">
|
||||
<div class="an-block an-block--flow">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<div class="an-block-featured" th:if="${!#lists.isEmpty(block.items)}"
|
||||
th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="an-feat-cover an-feat-cover--row" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
|
||||
<span class="an-feat-meta">
|
||||
<span class="an-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="an-block-list" th:if="${#lists.size(block.items) > 1}">
|
||||
<li class="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="an-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态三:日期方块列表(homeStyle=compact)——官网最典型的「方块日期 + 标题」行式 -->
|
||||
<section class="an-section an-container" th:fragment="compact(block)" aria-label="栏目区块">
|
||||
<div class="an-block an-block--flow">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">通知公告</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="an-block-item" th:each="item : ${block.items}">
|
||||
<span class="an-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列行式 -->
|
||||
<section class="an-section an-container" th:fragment="media_focus(block)" aria-label="栏目区块">
|
||||
<div class="an-block an-block--flow">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">媒体聚焦</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<ul class="an-block-list an-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="an-block-item" th:each="item : ${block.items}">
|
||||
<span class="an-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
|
||||
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<time class="an-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,四列格 -->
|
||||
<section class="an-section an-container" th:fragment="image_wall(block)" aria-label="栏目区块">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">图片新闻</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<ul class="an-wall" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="an-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="an-wall-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/academic-navy/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
|
||||
<section class="an-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="an-container an-ticker-inner">
|
||||
<span class="an-ticker-label" th:text="${block.title}">要闻速递</span>
|
||||
<ul class="an-ticker-list">
|
||||
<li th:each="item : ${block.items}">
|
||||
<a class="an-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
|
||||
<section class="an-section an-container an-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="an-block-head">
|
||||
<h2 th:text="${block.title}">线上展厅</h2>
|
||||
<i class="an-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p class="an-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
|
||||
Online Exhibition Hall
|
||||
</p>
|
||||
<ul class="an-wall an-wall--4">
|
||||
<li class="an-wall-item" th:each="g : ${block.items}">
|
||||
<a th:href="${g.linkUrl}" th:title="${g.title}">
|
||||
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
|
||||
<span class="an-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤) -->
|
||||
<section class="an-section an-container" th:fragment="links(block)" aria-label="友情链接">
|
||||
<div class="an-block an-block--flow">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.title}">友情链接</h2>
|
||||
</div>
|
||||
<th:block th:each="grp : ${block.linkGroups}">
|
||||
<p class="an-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
|
||||
<ul class="an-links">
|
||||
<li th:each="l : ${grp.links}">
|
||||
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
|
||||
<div class="an-empty" th:fragment="empty(block)">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="an-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="an-home" id="main-content">
|
||||
<!-- 首页轮播位(cms_banner position=home;设计尺寸见 theme.json bannerSize) -->
|
||||
<section class="an-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
|
||||
<div class="an-banner-slides">
|
||||
<a class="an-banner-slide" th:each="b, stat : ${banners}"
|
||||
th:classappend="${stat.first} ? 'active'"
|
||||
th:href="${b.linkUrl}" th:title="${b.title}">
|
||||
<img th:src="${b.imageUrl}" th:alt="${b.title}">
|
||||
<span class="an-banner-title" th:text="${b.title}">标题</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="an-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
|
||||
<button class="an-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
|
||||
<div class="an-banner-dots" th:if="${#lists.size(banners) > 1}">
|
||||
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
|
||||
th:aria-label="${'第' + (stat.count) + '张'}"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
|
||||
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
|
||||
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:switch="${block.type}">
|
||||
<th:block th:case="'article_list'">
|
||||
<th:block th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
|
||||
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<section class="an-section an-container" aria-label="头条与图片新闻">
|
||||
<div class="an-headline">
|
||||
<div class="an-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<div class="an-block-head">
|
||||
<h2>头条推荐</h2>
|
||||
<i class="an-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ul class="an-headline-list">
|
||||
<li class="an-headline-lead" th:with="lead=${topArticles[0]}">
|
||||
<a class="an-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
|
||||
<div class="an-lead-meta">
|
||||
<span class="an-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</li>
|
||||
<li th:each="item, stat : ${topArticles}" th:if="${stat.index > 0}">
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="an-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
|
||||
<div class="an-block-head">
|
||||
<h2>图片新闻</h2>
|
||||
<i class="an-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<a class="an-focus-lead" th:with="cover=${focusArticles[0]}"
|
||||
th:href="${url.article(cover.id)}">
|
||||
<span class="an-focus-cover">
|
||||
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
|
||||
</span>
|
||||
<span class="an-focus-title" th:text="${cover.title}">图片新闻标题</span>
|
||||
</a>
|
||||
<ul class="an-focus-rows">
|
||||
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
|
||||
<a th:href="${url.article(row.id)}">
|
||||
<span class="an-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
|
||||
<span class="an-row-title" th:text="${row.title}">图片新闻标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8) -->
|
||||
<section class="an-section an-container" aria-label="栏目区块">
|
||||
<div class="an-blocks">
|
||||
<div class="an-block" th:each="block : ${categoryBlocks}">
|
||||
<div class="an-block-head an-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
<a class="an-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<ul class="an-block-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
|
||||
<li class="an-block-featured" th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="an-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
|
||||
</span>
|
||||
<span class="an-feat-body">
|
||||
<span class="an-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="an-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
|
||||
th:text="${feat.summary}">摘要</span>
|
||||
<span class="an-feat-meta">
|
||||
<span class="an-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="an-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<span class="an-date-chip" th:if="${item.publishTime != null}"
|
||||
th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="an-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="an-empty" th:if="${#lists.isEmpty(block.items)}">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="an-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="an-page an-container" id="main-content">
|
||||
<div class="an-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="an-side-nav-list">
|
||||
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="an-page-card">
|
||||
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode != true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node, st : ${categoryPath}">
|
||||
<span class="an-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
|
||||
th:text="${node.name}">父栏目</a>
|
||||
<span class="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
|
||||
</th:block>
|
||||
</nav>
|
||||
<nav class="an-crumb" aria-label="面包屑" th:if="${searchMode == true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="an-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="an-crumb-current">搜索结果</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="an-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
|
||||
<h1 class="an-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
|
||||
|
||||
<ul class="an-article-list">
|
||||
<!-- 有封面:图文行;无封面:行式(符号 + 标题 + 右侧日期) -->
|
||||
<li th:each="item : ${listData.items}">
|
||||
<div class="an-article-row" th:if="${item.coverImage != null}">
|
||||
<a class="an-item-cover" th:href="${url.article(item.id)}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
|
||||
</a>
|
||||
<div class="an-item-body">
|
||||
<div class="an-item-title">
|
||||
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
</div>
|
||||
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
<div class="an-item-meta">
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="an-article-line" th:if="${item.coverImage == null}">
|
||||
<i class="an-line-tick" aria-hidden="true"></i>
|
||||
<div class="an-line-main">
|
||||
<a class="an-line-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<p class="an-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
</div>
|
||||
<div class="an-line-side">
|
||||
<span class="an-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="an-empty" th:if="${#lists.isEmpty(listData.items)}">
|
||||
<p th:if="${searchMode == true}">未找到相关内容</p>
|
||||
<p th:if="${searchMode != true}">栏目暂无内容</p>
|
||||
<p class="an-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
|
||||
<p class="an-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页(列表页/搜索页共用) -->
|
||||
<nav class="an-pagination" th:if="${listData.totalPages > 1}"
|
||||
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
|
||||
<a th:classappend="${cur <= 1} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
|
||||
th:aria-disabled="${cur <= 1}">上一页</a>
|
||||
<a th:each="p : ${#numbers.sequence(1, total)}"
|
||||
th:classappend="${p == cur} ? 'active'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
|
||||
th:aria-label="${'第' + p + '页'}"
|
||||
th:text="${p}">1</a>
|
||||
<a th:classappend="${cur >= total} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
|
||||
th:aria-disabled="${cur >= total}">下一页</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="an-page an-container" id="main-content">
|
||||
<div class="an-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="an-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="an-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="an-side-nav-list">
|
||||
<li class="an-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="an-page-card">
|
||||
<nav class="an-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node, st : ${categoryPath}">
|
||||
<span class="an-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
|
||||
th:text="${node.name}">父栏目</a>
|
||||
<span class="an-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
|
||||
</th:block>
|
||||
</nav>
|
||||
|
||||
<article class="an-article">
|
||||
<h1 class="an-article-title" th:text="${category.name}">栏目名</h1>
|
||||
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
|
||||
<div class="an-article-content" th:if="${!#strings.isEmpty(category.content)}"
|
||||
th:utext="${category.content}">单页内容</div>
|
||||
<div class="an-empty" th:if="${#strings.isEmpty(category.content)}">
|
||||
<p>页面暂无内容</p>
|
||||
<p class="an-empty-hint">内容编辑后将在此展示</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"specVersion": 1,
|
||||
"name": "academic-navy",
|
||||
"title": "学府藏蓝(academic-navy)",
|
||||
"version": "1.0.0",
|
||||
"author": "yunhai",
|
||||
"description": "学府藏蓝·高校官网门户:藏蓝顶栏+白底主导航+金线标题+日期方块列表+双栏要闻(左图右列)+深色三段页脚。基座 scholar-blue,配色与首页区块形态按综合类高校官网重做。",
|
||||
"screenshot": "assets/screenshot.png",
|
||||
"bannerSize": "1920x560",
|
||||
"params": [
|
||||
{ "key": "primary", "cssVar": "--an-primary", "label": "主色", "type": "color", "default": "#0b4a8f" },
|
||||
{ "key": "primaryDark", "cssVar": "--an-primary-dark", "label": "主色加深", "type": "color", "default": "#063161" },
|
||||
{ "key": "primaryLight", "cssVar": "--an-primary-light","label": "主色提亮", "type": "color", "default": "#1a6cb5" },
|
||||
{ "key": "accent", "cssVar": "--an-accent", "label": "点缀金", "type": "color", "default": "#b8934a" },
|
||||
{ "key": "navBg", "cssVar": "--an-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
|
||||
{ "key": "footerBg", "cssVar": "--an-footer-bg", "label": "页脚底色", "type": "color", "default": "#0a2b4d" },
|
||||
{ "key": "text", "cssVar": "--an-text", "label": "正文文字色", "type": "color", "default": "#2b3644" },
|
||||
{ "key": "textLight", "cssVar": "--an-text-light", "label": "次要文字色", "type": "color", "default": "#6b7889" },
|
||||
{ "key": "bg", "cssVar": "--an-bg", "label": "页面底色", "type": "color", "default": "#f1f4f9" },
|
||||
{ "key": "cardBg", "cssVar": "--an-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
|
||||
{ "key": "border", "cssVar": "--an-border", "label": "边框色", "type": "color", "default": "#dbe3ee" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# agri-forest · 农林绿
|
||||
|
||||
云海 CMS 皮肤包。fork 自 `default`(数据契约、模板 th:* 绑定与 default 完全一致),视觉层按农林院校门户语言全部重写,设计语言参考信阳农林学院官网(仅学语言,不复制实现)。
|
||||
|
||||
## 风格定位
|
||||
|
||||
- **气质词**:庄重、质朴、农林学府。
|
||||
- **配色**:墨绿 `#1C6E19` 主导(通栏导航/日期方块/页脚主底),加深端 `#14510F`(页脚/标题条),提亮端 `#3FA43A`(当前导航项/hover),麦穗金 `#C8A45C` 只做装饰线与角标(栏目标题下划线、导航当前项下杠、日期方块 hover 金线、置顶角标)。
|
||||
- **结构**:白色页头(左校徽+校名,右工具行:今日日期 + 校园直通车下拉 + 站内搜索)→ 墨绿通栏主导航(白字,当前栏目提亮绿底+金下杠,导航下方与 banner 无缝)→ 通栏轮播(左下墨绿标题条带金边)→ 首页区块 → 墨绿多列页脚(更深底条)。
|
||||
- **工艺**:小圆角 4px;间距刻度 4/8/12/16/24/32/48;字号阶梯 12/13/14/15/16/18/20/24/28/32;hover 过渡 0.2s ease;正文行高 1.75;无渐变堆砌、无粉紫、无玻璃拟态;图标一律内联 SVG。
|
||||
|
||||
## 参数取值建议(theme.json,13 项)
|
||||
|
||||
| key | cssVar | 默认 | 建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| primary | `--af-primary` | `#1C6E19` | 主色。换季校庆可微调为 `#1F7A1B`,勿低于 `#14510F` 明度(导航白字对比) |
|
||||
| primaryDark | `--af-primary-dark` | `#14510F` | 页脚主底/轮播标题条。与 primary 保持同色相 1~2 档差 |
|
||||
| primaryLight | `--af-primary-light` | `#3FA43A` | 当前导航项/hover。需要更醒目可用 `#4DB548`,过亮会刺眼 |
|
||||
| accent | `--af-accent` | `#C8A45C` | 麦穗金装饰线/角标。庄重场合可降饱和为 `#B8934A` |
|
||||
| navBg | `--af-nav-bg` | `#1C6E19` | 主导航底色。默认与 primary 一致;要"导航深一档"配 `#14510F` |
|
||||
| footerBg | `--af-footer-bg` | `#14510F` | 页脚主底;底条固定更深一档(`--af-primary-deep`,非参数) |
|
||||
| text / textLight | `--af-text` / `--af-text-light` | `#2A3327` / `#6C7A67` | 正文/次要文字,偏绿灰 |
|
||||
| bg | `--af-bg` | `#F5F8F3` | 页面底色,带一点绿调的暖白 |
|
||||
| cardBg | `--af-card-bg` | `#FFFFFF` | 卡片底,建议保持纯白 |
|
||||
| border | `--af-border` | `#DDE8DA` | 边框/分隔线,绿灰调 |
|
||||
| linkGreen | `--af-link` | `#277428` | 正文链接绿,介于主色深浅之间 |
|
||||
| listLayout | —(select) | `date` | 列表页形态:`date`=46px 日期方块行(农林官网典型);`plain`=通栏行式(日期右置),信息密度更高 |
|
||||
|
||||
微调优先级:站点 `theme_params`(本表参数)→ 站点 `custom_css` → 本包 main.css(契约 §6)。
|
||||
|
||||
## 首页九段形态表(fragments/home-blocks.html)
|
||||
|
||||
站点配 `home_layout` 时首页正文由本包 fragment 渲染(换肤必须连正文一起换):
|
||||
|
||||
| fragment | 触发 | 形态 |
|
||||
| --- | --- | --- |
|
||||
| `news_grid` | article_list + homeStyle=news_grid | 三图焦点(横排 3 封面卡)+ 双列要闻行式列表;支持站点背景带(news_bg_image,画在卡片之下) |
|
||||
| `media_focus` | article_list + homeStyle=media_focus | 学术动态:双列密行 + 墨绿来源徽标 |
|
||||
| `compact` | article_list + homeStyle=compact | 通知公告:46px 墨绿日期方块(日大号+年月小号),行 hover 出金线 |
|
||||
| `image_wall` | article_list + homeStyle=image_wall | 四列图片墙(仅有封面条目),悬浮标题 |
|
||||
| `card` | article_list 其他/未设 | 默认卡:头条图文(有封面则图文)+ 行式列表 |
|
||||
| `ticker` | type=ticker | 要闻速递横条:墨绿底、金色分隔、单行不塌列 |
|
||||
| `gallery` | type=gallery | 校园映像:居中标题+副题+四列图墙+悬浮标题 |
|
||||
| `links` | type=links | 专题网站:4 列缩略卡(绿标题条+金下杠+圆徽),分组拉平 |
|
||||
| `empty` | 各形态 items 为空 | 空态「栏目暂无内容」(守卫包在外层 th:block,th:replace 优先级 100 先于 th:if 300) |
|
||||
|
||||
未知类型回落 `themes/common` 通用骨架;`:root` 已把 `--af-*` 别名到契约变量(`--primary/--text/--card-bg/--border/--bg/--radius`),兜底骨架与 themeVarsCss 换色可穿透。
|
||||
|
||||
## 纪律要点
|
||||
|
||||
- URL 一律 `url.*` 生成,无硬编码绝对地址;非转义输出仅契约七处白名单。
|
||||
- 中文防塌列:`.af-ticker-list{min-width:0;white-space:nowrap}`、`.af-ticker-item{flex:none;text-overflow:ellipsis}`、`.af-block-item-title{min-width:0;overflow:hidden;text-overflow:ellipsis}`。
|
||||
- 网格一律 `minmax(0,1fr)`;≤768px 单列、导航横滚、子菜单静态平铺。
|
||||
- 背景带层序:`.af-sec-bg` z-index:0,其后 `.af-block` z-index:1。
|
||||
|
||||
## 截图说明
|
||||
|
||||
`assets/screenshot.png` 为按本皮肤设计语言绘制的门户结构示意图(非浏览器实拍)。管理端选择器如需实拍图,起服后截首页替换即可(记入遗留项)。
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,87 @@
|
||||
/* agri-forest 皮肤包脚本:页头日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖 */
|
||||
(function () {
|
||||
/* 页头工具行日期(本地时间,YYYY年M月D日 星期X) */
|
||||
var dateEl = document.getElementById('siteDate');
|
||||
if (dateEl) {
|
||||
var now = new Date();
|
||||
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
|
||||
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
|
||||
+ now.getDate() + '日 星期' + week;
|
||||
}
|
||||
|
||||
/* 轮播 */
|
||||
var root = document.querySelector('.af-banner');
|
||||
var slides = document.querySelectorAll('.af-banner-slide');
|
||||
var dots = document.querySelectorAll('.af-banner-dots button');
|
||||
var prevBtn = document.querySelector('.af-banner-arrow.prev');
|
||||
var nextBtn = document.querySelector('.af-banner-arrow.next');
|
||||
if (!slides.length || !root) {
|
||||
return;
|
||||
}
|
||||
var current = 0;
|
||||
var timer = null;
|
||||
var INTERVAL = 5000;
|
||||
|
||||
function show(index) {
|
||||
slides[current].classList.remove('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.remove('active');
|
||||
}
|
||||
current = (index + slides.length) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if (slides.length > 1) {
|
||||
timer = window.setInterval(function () {
|
||||
show(current + 1);
|
||||
}, INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dots.forEach(function (dot, index) {
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
start();
|
||||
});
|
||||
});
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () {
|
||||
show(current - 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
show(current + 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
root.addEventListener('mouseenter', stop);
|
||||
root.addEventListener('mouseleave', start);
|
||||
root.addEventListener('focusin', stop);
|
||||
root.addEventListener('focusout', start);
|
||||
root.setAttribute('tabindex', '-1');
|
||||
root.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
show(current - 1);
|
||||
start();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
show(current + 1);
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
start();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="af-page af-container" id="main-content">
|
||||
<div class="af-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="af-side-nav-list">
|
||||
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="af-page-card">
|
||||
<nav class="af-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node : ${categoryPath}">
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||
</th:block>
|
||||
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||
<span class="af-crumb-current">正文</span>
|
||||
</nav>
|
||||
|
||||
<article class="af-article">
|
||||
<h1 class="af-article-title" th:text="${article.title}">文章标题</h1>
|
||||
<div class="af-article-meta">
|
||||
<time th:if="${article.publishTime != null}"
|
||||
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
|
||||
<span th:if="${!#strings.isEmpty(article.authorName)}"
|
||||
th:text="'作者:' + ${article.authorName}">作者</span>
|
||||
<span th:if="${!#strings.isEmpty(article.source)}"
|
||||
th:text="'来源:' + ${article.source}">来源</span>
|
||||
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
|
||||
</div>
|
||||
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
|
||||
其余字段一律 th:text 转义输出 -->
|
||||
<div class="af-article-content" th:utext="${article.content}">正文</div>
|
||||
</article>
|
||||
|
||||
<nav class="af-sibling" aria-label="上下篇导航">
|
||||
<a th:if="${prevArticle != null}" class="prev"
|
||||
th:href="${url.article(prevArticle.id)}">
|
||||
<span class="sib-label">上一篇</span>
|
||||
<span class="sib-title" th:text="${prevArticle.title}">上一篇标题</span>
|
||||
</a>
|
||||
<span class="sib-none prev" th:if="${prevArticle == null}">
|
||||
<span class="sib-label">上一篇</span>
|
||||
<span class="sib-title">已经是最早一篇</span>
|
||||
</span>
|
||||
<a th:if="${nextArticle != null}" class="next"
|
||||
th:href="${url.article(nextArticle.id)}">
|
||||
<span class="sib-label">下一篇</span>
|
||||
<span class="sib-title" th:text="${nextArticle.title}">下一篇标题</span>
|
||||
</a>
|
||||
<span class="sib-none next" th:if="${nextArticle == null}">
|
||||
<span class="sib-label">下一篇</span>
|
||||
<span class="sib-title">已是最新一篇</span>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- 公共页脚(农林绿·墨绿多列):品牌区(校徽+站名+社媒图标) + 地址/联系/版权/备案 + 二维码位
|
||||
+ 友链分组(浅色带)+ 更深底条(校名)。
|
||||
数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName),
|
||||
增强键(docs/12):footerAddressLines / qr_wechat / qr_weibo / social_*(有值才渲染,空站点优雅降级)。 -->
|
||||
<footer class="af-footer" th:fragment="footer">
|
||||
<!-- 主区:墨绿多列 -->
|
||||
<div class="af-footer-main">
|
||||
<div class="af-container af-footer-cols">
|
||||
<!-- 品牌列 -->
|
||||
<div class="af-footer-brand">
|
||||
<div class="af-footer-logo-row">
|
||||
<img th:if="${site.config['logo'] != null}" class="af-footer-logo"
|
||||
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
<span class="af-footer-site" th:text="${site.name}">站点名称</span>
|
||||
</div>
|
||||
<!-- 社媒主页(url 校验,有值才渲染对应图标;图标内联 SVG,契约 §4.6 不引外链) -->
|
||||
<div class="af-social" th:if="${site.config['social_wechat'] != null
|
||||
or site.config['social_weibo'] != null
|
||||
or site.config['social_douyin'] != null
|
||||
or site.config['social_bilibili'] != null}">
|
||||
<a th:if="${site.config['social_wechat'] != null}" class="af-social-icon"
|
||||
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/></svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_weibo'] != null}" class="af-social-icon"
|
||||
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/></svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_douyin'] != null}" class="af-social-icon"
|
||||
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/></svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_bilibili'] != null}" class="af-social-icon"
|
||||
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true"><path fill="currentColor" d="M7.2 4.3 9 6.4h6l1.8-2.1c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 地址/联系列(多校区地址按行直出;contact/copyright/beian 可空,空块收敛) -->
|
||||
<div class="af-footer-info">
|
||||
<ul class="af-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
|
||||
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
|
||||
</ul>
|
||||
<p class="af-footer-line" th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
|
||||
<p class="af-footer-line" th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
|
||||
<p class="af-footer-line" th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码位(asset 校验,有值才渲染) -->
|
||||
<div class="af-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
|
||||
<figure class="af-qr" th:if="${site.config['qr_wechat'] != null}">
|
||||
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
|
||||
<figcaption>官方微信</figcaption>
|
||||
</figure>
|
||||
<figure class="af-qr" th:if="${site.config['qr_weibo'] != null}">
|
||||
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
|
||||
<figcaption>官方微博</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 友链区(浅绿带;无分组的站整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
|
||||
<div class="af-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||
<div class="af-container af-footer-links-inner">
|
||||
<div class="af-links-group" th:each="group : ${site.linkGroups}"
|
||||
th:if="${!#lists.isEmpty(group.links)}">
|
||||
<span class="af-group-name" th:text="${group.groupName}">分组</span>
|
||||
<p class="af-links-row">
|
||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
|
||||
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="af-link-logo"
|
||||
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||
<span th:text="${link.name}">链接</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底条(更深墨绿):全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
|
||||
<div class="af-footer-bottom">
|
||||
<div class="af-container af-footer-bottom-inner">
|
||||
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
|
||||
th:text="${site.schName}">学校名称</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/8.6),与 default 数据契约一致 -->
|
||||
<head th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle}">页面标题</title>
|
||||
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
||||
th:content="${seoDescription}">
|
||||
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
|
||||
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
|
||||
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
|
||||
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
|
||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||
<link rel="icon" type="image/png"
|
||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||
</head>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- 公共头部(农林绿):白色页头(左校徽+校名 / 右工具行:日期+校园直通车下拉+搜索)
|
||||
+ 墨绿通栏主导航(白字,当前栏目 primaryLight 底),导航下方与 banner 无缝衔接。
|
||||
数据契约与 default 完全一致:topbar 日期由 main.js 填充、专业院系下拉(site.colleges 空则不渲染)。 -->
|
||||
<header class="af-header" th:fragment="header">
|
||||
<a class="af-skip-link" href="#main-content">跳到主要内容</a>
|
||||
|
||||
<!-- 白色页头 -->
|
||||
<div class="af-head">
|
||||
<div class="af-head-inner">
|
||||
<!-- 左:校徽 + 校名 -->
|
||||
<div class="af-brand">
|
||||
<img th:if="${site.config['logo'] != null}" class="af-logo"
|
||||
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
<a class="af-sitename" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
|
||||
</div>
|
||||
<!-- 右:工具行(今日日期 + 校园直通车 + 站内搜索);无子站站点下拉整块不渲染 -->
|
||||
<div class="af-head-side">
|
||||
<span class="af-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
|
||||
<div class="af-colleges" th:if="${!site.colleges.isEmpty()}"
|
||||
th:with="collegesLabel=${site.config['colleges_label'] ?: '校园直通车'}">
|
||||
<span class="af-colleges-toggle">
|
||||
<a href="javascript:void(0)" th:text="${collegesLabel}">校园直通车</a>
|
||||
<i class="af-colleges-arrow" aria-hidden="true">▾</i>
|
||||
</span>
|
||||
<ul class="af-colleges-menu">
|
||||
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
|
||||
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
|
||||
th:title="${col.name}" th:text="${col.name}">学院名称</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<form class="af-search" th:action="${url.search('', null)}" method="get" role="search">
|
||||
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 墨绿通栏主导航:外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
|
||||
<nav class="af-nav" aria-label="栏目导航">
|
||||
<ul>
|
||||
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
</li>
|
||||
<li th:each="cat : ${navCategories}"
|
||||
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
|
||||
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${cat.name}">栏目</a>
|
||||
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="af-caret" aria-hidden="true">▾</i></a>
|
||||
<!-- 二级下拉:白底面板(.af-sub-nav 样式见 assets/css/main.css);当前项高亮(docs/11) -->
|
||||
<ul class="af-sub-nav" th:if="${!cat.children.isEmpty()}">
|
||||
<li th:each="sub : ${cat.children}"
|
||||
th:classappend="${sub.id == currentCategoryId} ? 'active'">
|
||||
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${sub.name}">子栏目</a>
|
||||
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</html>
|
||||
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!--
|
||||
agri-forest(农林绿)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
|
||||
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
|
||||
九段形态:news_grid(三图焦点+要闻列表)/ media_focus(来源徽标密行)/ compact(46px 日期方块行)/
|
||||
image_wall(四列图片墙)/ card(默认卡:头条+行式)/ ticker(速递横条)/ gallery(校园映像图廊)/
|
||||
links(专题网站缩略卡)/ empty(空态)。
|
||||
契约:block 为渲染端装配的同构 Map(type/title/category.name/homeStyle/items/moreUrl/subtitle/
|
||||
background/source/linkGroups),与回落模式 categoryBlocks 兼容;区块标题一律取 category.name。
|
||||
空态守卫必须包在 th:block 外层:th:replace(优先级 100)先于 th:if(300)求值,写在同一标签上等于没写。
|
||||
-->
|
||||
<!-- 形态一:三图焦点 + 要闻列表(homeStyle=news_grid)——横排 3 张封面卡 + 双列行式列表 -->
|
||||
<section class="af-section af-section--news" th:fragment="news_grid(block)" aria-label="栏目区块"
|
||||
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
|
||||
<div class="af-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
|
||||
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
|
||||
<div class="af-block af-container">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">学校要闻</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<div class="af-news-body" th:if="${!#lists.isEmpty(block.items)}"
|
||||
th:with="rest=${#lists.size(block.items) > 3 ? block.items.subList(3, block.items.size()) : null}">
|
||||
<ul class="af-trio">
|
||||
<li class="af-trio-item" th:each="item, stat : ${block.items}" th:if="${stat.index < 3}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<span class="af-trio-cover" th:if="${item.coverImage != null}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="af-trio-cover af-trio-ph" th:if="${item.coverImage == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M5 19C5 9 12 4 20 4c0 9-5 15-12 15-1.5 0-3-.4-3-1z"/><path d="M5 19c3-5 6-8 10-10"/></svg>
|
||||
</span>
|
||||
<span class="af-trio-cap">
|
||||
<span class="af-trio-title" th:text="${item.title}">焦点标题</span>
|
||||
<span class="af-trio-meta">
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="af-block-list af-block-list--two" th:if="${rest != null}">
|
||||
<li class="af-line" th:each="item : ${rest}">
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态二:来源徽标密行(homeStyle=media_focus)——学术动态,双列紧凑行 + 来源徽标 -->
|
||||
<section class="af-section af-container" th:fragment="media_focus(block)" aria-label="栏目区块">
|
||||
<div class="af-block">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M9 3h6M10 3v6L4.6 18A2 2 0 0 0 6.3 21h11.4a2 2 0 0 0 1.7-3L14 9V3"/><path d="M7.5 15h9"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">学术动态</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<ul class="af-block-list af-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="af-line af-line--media" th:each="item : ${block.items}">
|
||||
<span class="af-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">来源</span>
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态三:46px 日期方块列表(homeStyle=compact)——通知公告:日大号 + 年月小号,墨绿底白字,hover 金线 -->
|
||||
<section class="af-section af-container" th:fragment="compact(block)" aria-label="栏目区块">
|
||||
<div class="af-block">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">通知公告</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="af-line af-line--date" th:each="item : ${block.items}">
|
||||
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态四:四列图片墙(homeStyle=image_wall)——仅取有封面的条目,悬浮标题 -->
|
||||
<section class="af-section af-container" th:fragment="image_wall(block)" aria-label="栏目区块">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">图片新闻</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<ul class="af-wall" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="af-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="af-wall-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</section>
|
||||
|
||||
<!-- 形态五:默认卡(homeStyle 未设/未知)——整宽头条(有封面则图文)+ 行式列表 -->
|
||||
<section class="af-section af-container" th:fragment="card(block)" aria-label="栏目区块">
|
||||
<div class="af-block">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<div class="af-feat" th:if="${!#lists.isEmpty(block.items)}" th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="af-feat-body">
|
||||
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
|
||||
th:text="${feat.summary}">摘要</span>
|
||||
<span class="af-feat-meta">
|
||||
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="af-block-list" th:if="${#lists.size(block.items) > 1}">
|
||||
<li class="af-line" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/agri-forest/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
|
||||
<section class="af-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="af-container af-ticker-inner">
|
||||
<span class="af-ticker-label">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3 10v4l6 1 9 5V4L9 9l-6 1z"/><path d="M7 15l1.5 5h3L10 16"/></svg>
|
||||
<span th:text="${block.title}">要闻速递</span>
|
||||
</span>
|
||||
<ul class="af-ticker-list">
|
||||
<li th:each="item : ${block.items}">
|
||||
<a class="af-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:校园映像图廊(block.items = 轮播位 gallery,四列图片墙 + 悬浮标题) -->
|
||||
<section class="af-section af-container af-gallery" th:fragment="gallery(block)" aria-label="校园映像"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="af-gallery-head">
|
||||
<div class="af-block-title af-gallery-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.title}">校园映像</h2>
|
||||
</div>
|
||||
<p class="af-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
|
||||
Campus Gallery
|
||||
</p>
|
||||
</div>
|
||||
<ul class="af-wall af-wall--4">
|
||||
<li class="af-wall-item" th:each="g : ${block.items}">
|
||||
<a th:href="${g.linkUrl}" th:title="${g.title}">
|
||||
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
|
||||
<span class="af-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:专题网站缩略卡(block.linkGroups 由渲染端按 group 过滤;4 列卡片含标题条) -->
|
||||
<section class="af-section af-container" th:fragment="links(block)" aria-label="专题网站">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.title}">专题网站</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="af-topic-grid">
|
||||
<th:block th:each="grp : ${block.linkGroups}">
|
||||
<li class="af-topic" th:each="l : ${grp.links}">
|
||||
<a th:href="${l.linkUrl}" target="_blank" rel="noopener" th:title="${l.name}">
|
||||
<span class="af-topic-bar" aria-hidden="true"></span>
|
||||
<span class="af-topic-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14 0 18M12 3c-3 3.5-3 14 0 18"/></svg>
|
||||
</span>
|
||||
<span class="af-topic-name" th:text="${l.name}">专题名称</span>
|
||||
</a>
|
||||
</li>
|
||||
</th:block>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
|
||||
<div class="af-empty" th:fragment="empty(block)">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="af-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<!-- 事项指南页(事项库 §6.2 契约):item / attachments[] / relatedItems[] /
|
||||
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
|
||||
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
|
||||
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理" -->
|
||||
<main class="af-page af-container" id="main-content">
|
||||
<div class="af-page-card af-svc-guide">
|
||||
<nav class="af-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:href="${url.serviceList()}">服务中心</a>
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="af-crumb-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
|
||||
</nav>
|
||||
|
||||
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
|
||||
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
|
||||
<p>事项服务暂时不可用,请稍后再试</p>
|
||||
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
|
||||
</div>
|
||||
|
||||
<article class="af-svc-guide-body" th:if="${!svcUnavailable}">
|
||||
<h1 class="af-article-title" th:text="${item.title}">事项名称</h1>
|
||||
<div class="af-article-meta">
|
||||
<span th:if="${!#strings.isEmpty(item.deptName)}"
|
||||
th:text="${'办理部门:' + item.deptName}">办理部门</span>
|
||||
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
|
||||
</div>
|
||||
|
||||
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
|
||||
<div class="af-svc-apply" th:if="${applyUrl != null}">
|
||||
<a class="af-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
|
||||
<span class="af-svc-apply-hint">进入服务大厅在线办理</span>
|
||||
</div>
|
||||
|
||||
<p class="af-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
|
||||
|
||||
<table class="af-svc-info">
|
||||
<caption class="af-skip-link">事项基本信息</caption>
|
||||
<tbody>
|
||||
<tr th:if="${!#strings.isEmpty(item.objectName)}">
|
||||
<th scope="row">服务对象</th>
|
||||
<td th:text="${item.objectName}">服务对象</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
|
||||
<th scope="row">事项类别</th>
|
||||
<td th:text="${item.categoryName}">事项类别</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
|
||||
<th scope="row">办理地点</th>
|
||||
<td th:text="${item.handlePlace}">办理地点</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
|
||||
<th scope="row">办理时限</th>
|
||||
<td th:text="${item.handleTimeLimit}">办理时限</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
|
||||
<th scope="row">承诺时限</th>
|
||||
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
|
||||
<th scope="row">咨询电话</th>
|
||||
<td th:text="${item.contactPhone}">咨询电话</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
|
||||
<th scope="row">监督投诉</th>
|
||||
<td th:text="${item.supervisePhone}">监督投诉电话</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
|
||||
<h2>办理流程</h2>
|
||||
<div class="af-article-content" th:utext="${item.processHtml}">办理流程</div>
|
||||
</section>
|
||||
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
|
||||
<h2>所需材料</h2>
|
||||
<div class="af-article-content" th:utext="${item.materialHtml}">所需材料</div>
|
||||
</section>
|
||||
<section class="af-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
|
||||
<h2>注意事项</h2>
|
||||
<div class="af-article-content" th:utext="${item.noticeHtml}">注意事项</div>
|
||||
</section>
|
||||
|
||||
<section class="af-svc-section af-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
|
||||
<h2>相关材料下载</h2>
|
||||
<ul>
|
||||
<li th:each="att : ${attachments}">
|
||||
<a th:href="${url.upload(att.webUrl)}" th:download
|
||||
th:text="${att.fileName}">材料文件</a>
|
||||
<span class="af-svc-att-size" th:if="${att.fileSize != null}"
|
||||
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<nav class="af-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
|
||||
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
|
||||
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
|
||||
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<aside class="af-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
|
||||
<h2 class="af-section-title">相关服务</h2>
|
||||
<ul class="af-svc-related-list">
|
||||
<li th:each="rel : ${relatedItems}">
|
||||
<a th:href="${rel.url}">
|
||||
<span class="af-svc-related-name" th:text="${rel.title}">相关事项</span>
|
||||
<span class="af-svc-related-meta">
|
||||
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
|
||||
<span class="af-svc-tag"
|
||||
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,169 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="af-home" id="main-content">
|
||||
<!-- 首页轮播位(cms_banner position=home;设计尺寸 1920×560,见 theme.json bannerSize):
|
||||
干净切图 + 左下墨绿标题条 -->
|
||||
<section class="af-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
|
||||
<div class="af-banner-slides">
|
||||
<a class="af-banner-slide" th:each="b, stat : ${banners}"
|
||||
th:classappend="${stat.first} ? 'active'"
|
||||
th:href="${b.linkUrl}" th:title="${b.title}">
|
||||
<img th:src="${b.imageUrl}" th:alt="${b.title}">
|
||||
<span class="af-banner-title" th:text="${b.title}">标题</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="af-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
|
||||
<button class="af-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
|
||||
<div class="af-banner-dots" th:if="${#lists.size(banners) > 1}">
|
||||
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
|
||||
th:aria-label="${'第' + (stat.count) + '张'}"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
|
||||
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
|
||||
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:switch="${block.type}">
|
||||
<th:block th:case="'article_list'">
|
||||
<th:block th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
|
||||
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<!-- 三图焦点:图片新闻(focusArticles 必有封面)横排 3 卡 -->
|
||||
<section class="af-section af-container" aria-label="图片新闻" th:if="${!#lists.isEmpty(focusArticles)}">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="3" y="5" width="18" height="14" rx="1"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 15.5l-4.5-4.5-9 8"/></svg>
|
||||
</span>
|
||||
<h2>图片新闻</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="af-trio">
|
||||
<li class="af-trio-item" th:each="item : ${focusArticles}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<span class="af-trio-cover">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
|
||||
</span>
|
||||
<span class="af-trio-cap">
|
||||
<span class="af-trio-title" th:text="${item.title}">图片新闻标题</span>
|
||||
<span class="af-trio-meta">
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 头条区:头条推荐(置顶优先)大卡 + 右侧密行 -->
|
||||
<section class="af-section af-container" aria-label="头条推荐">
|
||||
<div class="af-headline" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<div class="af-block">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M4 5h11a1 1 0 0 1 1 1v13H6a2 2 0 0 1-2-2V5z"/><path d="M16 9h3a1 1 0 0 1 1 1v7a2 2 0 0 1-2 2"/><path d="M7 9h6M7 13h6M7 17h4"/></svg>
|
||||
</span>
|
||||
<h2>头条推荐</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-headline-body"
|
||||
th:with="rest=${#lists.size(topArticles) > 1 ? topArticles.subList(1, topArticles.size()) : null}">
|
||||
<div class="af-headline-lead" th:with="lead=${topArticles[0]}">
|
||||
<a class="af-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
|
||||
<div class="af-lead-meta">
|
||||
<span class="af-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="af-block-list" th:if="${rest != null}">
|
||||
<li class="af-line" th:each="item : ${rest}">
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8,D1-2):通知公告形态(46px 日期方块) -->
|
||||
<section class="af-section af-container" aria-label="栏目区块">
|
||||
<div class="af-blocks">
|
||||
<div class="af-block" th:each="block : ${categoryBlocks}">
|
||||
<div class="af-block-head">
|
||||
<div class="af-block-title">
|
||||
<span class="af-block-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><path d="M6 3h8l4 4v14H6V3z"/><path d="M14 3v4h4M9 12h6M9 16h6"/></svg>
|
||||
</span>
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
</div>
|
||||
<a class="af-more" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<ul class="af-block-list af-notice-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
|
||||
<li class="af-feat-row" th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="af-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
|
||||
</span>
|
||||
<span class="af-feat-body">
|
||||
<span class="af-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="af-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
|
||||
th:text="${feat.summary}">摘要</span>
|
||||
<span class="af-feat-meta">
|
||||
<span class="af-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time class="af-item-date" th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="af-line af-line--date" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="af-empty" th:if="${#lists.isEmpty(block.items)}">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="af-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<!-- 列表页(含搜索结果页两用):themeParams['listLayout'] 切换形态——
|
||||
date=46px 日期方块行(默认,农林官网典型)/ plain=通栏行式(隐藏方块、日期右置) -->
|
||||
<main class="af-page af-container" id="main-content"
|
||||
th:classappend="${themeParams['listLayout'] == 'plain'} ? 'af-page--plainlist'">
|
||||
<div class="af-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="af-side-nav-list">
|
||||
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="af-page-card">
|
||||
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode != true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node, st : ${categoryPath}">
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:if="${!st.last}" class="af-crumb-parent" th:href="${url.category(node.id)}"
|
||||
th:text="${node.name}">父栏目</a>
|
||||
<span class="af-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
|
||||
</th:block>
|
||||
</nav>
|
||||
<nav class="af-crumb" aria-label="面包屑" th:if="${searchMode == true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="af-crumb-current">搜索结果</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="af-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
|
||||
<h1 class="af-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
|
||||
|
||||
<ul class="af-article-list">
|
||||
<!-- 有封面:图文行;无封面:日期方块行(plain 形态下自动切通栏行式),不造占位图 -->
|
||||
<li th:each="item : ${listData.items}">
|
||||
<div class="af-article-row" th:if="${item.coverImage != null}">
|
||||
<a class="af-item-cover" th:href="${url.article(item.id)}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
|
||||
</a>
|
||||
<div class="af-item-body">
|
||||
<div class="af-item-title">
|
||||
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
</div>
|
||||
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
<div class="af-item-meta">
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-article-line" th:if="${item.coverImage == null}">
|
||||
<span class="af-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<div class="af-line-main">
|
||||
<a class="af-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<p class="af-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
</div>
|
||||
<div class="af-line-side">
|
||||
<span class="af-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="af-line-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="af-empty" th:if="${#lists.isEmpty(listData.items)}">
|
||||
<p th:if="${searchMode == true}">未找到相关内容</p>
|
||||
<p th:if="${searchMode != true}">栏目暂无内容</p>
|
||||
<p class="af-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
|
||||
<p class="af-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页(列表页/搜索页共用) -->
|
||||
<nav class="af-pagination" th:if="${listData.totalPages > 1}"
|
||||
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
|
||||
<a th:classappend="${cur <= 1} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
|
||||
th:aria-disabled="${cur <= 1}">上一页</a>
|
||||
<a th:each="p : ${#numbers.sequence(1, total)}"
|
||||
th:classappend="${p == cur} ? 'active'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
|
||||
th:aria-label="${'第' + p + '页'}"
|
||||
th:text="${p}">1</a>
|
||||
<a th:classappend="${cur >= total} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
|
||||
th:aria-disabled="${cur >= total}">下一页</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<!-- 服务中心(事项库 §6.1 契约):serviceList / serviceFilters / activeFilters / q;
|
||||
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query -->
|
||||
<main class="af-page af-container" id="main-content">
|
||||
<div class="af-page-card af-svc-page">
|
||||
<nav class="af-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="af-crumb-current">服务中心</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="af-page-title">服务中心</h1>
|
||||
|
||||
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
|
||||
<div class="af-empty af-svc-unavailable" th:if="${svcUnavailable}">
|
||||
<p>事项服务暂时不可用,请稍后再试</p>
|
||||
<p class="af-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
|
||||
</div>
|
||||
|
||||
<div class="af-svc-filters"
|
||||
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
|
||||
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
|
||||
<span class="af-svc-filter-label">服务对象</span>
|
||||
<div class="af-svc-filter-options">
|
||||
<a class="af-svc-chip" th:each="opt : ${serviceFilters.objects}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
|
||||
<span class="af-svc-filter-label">事项类别</span>
|
||||
<div class="af-svc-filter-options">
|
||||
<a class="af-svc-chip" th:each="opt : ${serviceFilters.categories}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
|
||||
<span class="af-svc-filter-label">服务部门</span>
|
||||
<div class="af-svc-filter-options">
|
||||
<a class="af-svc-chip" th:each="opt : ${serviceFilters.depts}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-svc-toolbar" th:if="${!svcUnavailable}">
|
||||
<form class="af-svc-search" th:action="${url.serviceList()}" method="get" role="search">
|
||||
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
<a class="af-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
|
||||
</div>
|
||||
|
||||
<ul class="af-svc-list" th:if="${!svcUnavailable}">
|
||||
<li th:each="it : ${serviceList.items}">
|
||||
<a class="af-svc-item" th:href="${url.serviceItem(it.id)}">
|
||||
<span class="af-svc-item-cover" th:if="${it.coverImage != null}">
|
||||
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
|
||||
</span>
|
||||
<span class="af-svc-item-body">
|
||||
<span class="af-svc-item-title">
|
||||
<span class="af-svc-item-name" th:text="${it.title}">事项名称</span>
|
||||
<span class="af-svc-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
|
||||
<span class="af-svc-tag"
|
||||
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
|
||||
</span>
|
||||
<span class="af-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
|
||||
th:text="${it.summary}">简介</span>
|
||||
<span class="af-svc-item-meta">
|
||||
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
|
||||
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
|
||||
<time th:if="${it.publishTime != null}"
|
||||
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="af-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
|
||||
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
|
||||
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
|
||||
<p class="af-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
|
||||
<p class="af-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
|
||||
<nav class="af-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
|
||||
<a th:each="p : ${serviceList.pageUrls}"
|
||||
th:classappend="${p.active} ? 'active'"
|
||||
th:href="${p.url}"
|
||||
th:aria-label="${'第' + p.page + '页'}"
|
||||
th:aria-current="${p.active} ? 'page'"
|
||||
th:text="${p.page}">1</a>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="af-page af-container" id="main-content">
|
||||
<div class="af-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="af-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="af-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="af-side-nav-list">
|
||||
<li class="af-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="af-page-card">
|
||||
<nav class="af-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node : ${categoryPath}">
|
||||
<span class="af-crumb-sep" aria-hidden="true">/</span>
|
||||
<a class="af-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||
</th:block>
|
||||
<span class="af-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||
<span class="af-crumb-current">正文</span>
|
||||
</nav>
|
||||
|
||||
<article class="af-article">
|
||||
<h1 class="af-article-title" th:text="${category.name}">栏目名</h1>
|
||||
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
|
||||
<div class="af-article-content" th:if="${!#strings.isEmpty(category.content)}"
|
||||
th:utext="${category.content}">单页内容</div>
|
||||
<div class="af-empty" th:if="${#strings.isEmpty(category.content)}">
|
||||
<p>页面暂无内容</p>
|
||||
<p class="af-empty-hint">内容编辑后将在此展示</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"specVersion": 1,
|
||||
"name": "agri-forest",
|
||||
"title": "农林绿(agri-forest)",
|
||||
"version": "1.0.0",
|
||||
"author": "yunhai",
|
||||
"description": "墨绿+麦穗金的农林院校风(设计语言参考信阳农林学院官网)。",
|
||||
"screenshot": "assets/screenshot.png",
|
||||
"bannerSize": "1920x560",
|
||||
"params": [
|
||||
{ "key": "primary", "cssVar": "--af-primary", "label": "主色(墨绿)", "type": "color", "default": "#1C6E19" },
|
||||
{ "key": "primaryDark", "cssVar": "--af-primary-dark", "label": "主色加深", "type": "color", "default": "#14510F" },
|
||||
{ "key": "primaryLight", "cssVar": "--af-primary-light","label": "主色提亮", "type": "color", "default": "#3FA43A" },
|
||||
{ "key": "accent", "cssVar": "--af-accent", "label": "麦穗金", "type": "color", "default": "#C8A45C" },
|
||||
{ "key": "navBg", "cssVar": "--af-nav-bg", "label": "主导航底色", "type": "color", "default": "#1C6E19" },
|
||||
{ "key": "footerBg", "cssVar": "--af-footer-bg", "label": "页脚底色", "type": "color", "default": "#14510F" },
|
||||
{ "key": "text", "cssVar": "--af-text", "label": "正文文字色", "type": "color", "default": "#2A3327" },
|
||||
{ "key": "textLight", "cssVar": "--af-text-light", "label": "次要文字色", "type": "color", "default": "#6C7A67" },
|
||||
{ "key": "bg", "cssVar": "--af-bg", "label": "页面底色", "type": "color", "default": "#F5F8F3" },
|
||||
{ "key": "cardBg", "cssVar": "--af-card-bg", "label": "卡片底色", "type": "color", "default": "#FFFFFF" },
|
||||
{ "key": "border", "cssVar": "--af-border", "label": "边框色", "type": "color", "default": "#DDE8DA" },
|
||||
{ "key": "linkGreen", "cssVar": "--af-link", "label": "链接绿", "type": "color", "default": "#277428" },
|
||||
{ "key": "listLayout", "label": "列表页形态", "type": "select", "default": "date",
|
||||
"options": [
|
||||
{ "value": "date", "label": "日期方块" },
|
||||
{ "value": "plain", "label": "通栏行式" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# campus-azure(青蓝校园)
|
||||
|
||||
高校官网风皮肤包:以 `scholar-blue` 为基座派生,配色与首页区块形态按师范/农林类院校官网(信阳师范学院、
|
||||
信阳农林学院一类)重做。配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染,
|
||||
不落 `themes/common` 通用骨架(否则换肤只剩页头页脚)。
|
||||
|
||||
## 风格定位
|
||||
|
||||
- 主色青蓝 `#0d6a9e` + 加深 `#084c73` + 点缀青绿 `#2b9c8f`(列表圆点、标题线脚、速递标签走青绿)。
|
||||
- 圆角放宽到 6/12px、页面底 `#eef3f6`,比 academic-navy 更松更「校园」;区块头统一走居中双线 + 右置「更多」。
|
||||
- 全部 11 个颜色参数可在站点 theme_params 微调(`primary/primaryDark/primaryLight/accent/navBg/footerBg/text/textLight/bg/cardBg/border`)。
|
||||
|
||||
## 首页区块形态(布局模式与回落模式同构)
|
||||
|
||||
| homeStyle | 呈现 | 类名 |
|
||||
| -- | -- | -- |
|
||||
| `news_grid` | 图文要闻:全宽封面头条(底部渐变遮罩标题 + 元信息)+ 双列圆点密行;区块背景可取站点 `news_bg_image` | `cz-hero*` / `cz-block-list--two` |
|
||||
| `compact` | 圆点密行:青绿圆点 + 标题 + 右对齐完整日期 | `cz-item-bullet` / `cz-item-date` |
|
||||
| `media_focus` | 媒体聚焦:胶囊来源徽标 + 双列密行 | `cz-src` |
|
||||
| `image_wall` | 三列大格图墙:圆角卡 + 底部遮罩标题 | `cz-wall` |
|
||||
| (空/未知) | 整宽区块卡:左 2 份图文头条 + 右 3 份圆点行 | `cz-block--flow` / `cz-split` |
|
||||
| `ticker` | 青蓝要闻速递横条,标签为青绿胶囊(单行不换行、超长省略,中文不塌列) | `cz-ticker*` |
|
||||
| `gallery` | 线上展厅:居中双语文区头 + 四列图墙 | `cz-gallery` |
|
||||
| `links` | 友情链接分组(胶囊标签行) | `cz-links` |
|
||||
|
||||
## 契约要点
|
||||
|
||||
- 结构类前缀 `cz-`,CSS 变量前缀 `--cz-`;`:root` 把 `--cz-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
|
||||
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
|
||||
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
|
||||
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
|
||||
- 回归防线:`AcademicSkinContractTest`(自带区块 fragment + 中文不塌列 + 契约变量别名 + 主色必须学术蓝)、
|
||||
`PortalHomeLayoutIT#campusAzure_fallbackAndAssets`(回落分支 + 资产通道)、`ThemeContractIT`(7 条路由)。
|
||||
|
After Width: | Height: | Size: 721 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<rect width="96" height="96" rx="20" fill="#0a5aa8"/>
|
||||
<path d="M48 16l8.6 17.5 19.3 2.8-14 13.6 3.3 19.2L48 60.1 30.8 69.1l3.3-19.2-14-13.6 19.3-2.8L48 16z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,190 @@
|
||||
/* campus-azure 主题脚本:顶栏日期 + 轮播(圆点/箭头/触摸) + 导航吸顶反蓝 + 移动端抽屉与二级手风琴。
|
||||
与 default 同源逻辑,无任何外部依赖。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/* ---------- 顶栏日期(本地时间,YYYY年M月D日 星期X) ---------- */
|
||||
var dateEl = document.getElementById('siteDate');
|
||||
if (dateEl) {
|
||||
var now = new Date();
|
||||
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
|
||||
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
|
||||
+ now.getDate() + '日 星期' + week;
|
||||
}
|
||||
|
||||
/* ---------- 导航吸顶反蓝(桌面端;≤768 不吸顶) ---------- */
|
||||
var nav = document.getElementById('czNav');
|
||||
var DESKTOP_MIN = 769;
|
||||
var navBaseTop = 0;
|
||||
var holder = null;
|
||||
|
||||
function measureNavTop() {
|
||||
unlockNav();
|
||||
navBaseTop = nav.getBoundingClientRect().top + window.pageYOffset;
|
||||
}
|
||||
|
||||
function lockNav() {
|
||||
if (!nav.classList.contains('cz-nav--fixed')) {
|
||||
holder = document.createElement('div');
|
||||
holder.className = 'cz-nav-holder';
|
||||
holder.style.height = nav.offsetHeight + 'px';
|
||||
nav.parentNode.insertBefore(holder, nav);
|
||||
nav.classList.add('cz-nav--fixed');
|
||||
} else if (holder) {
|
||||
holder.style.height = nav.offsetHeight + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function unlockNav() {
|
||||
nav.classList.remove('cz-nav--fixed');
|
||||
if (holder && holder.parentNode) {
|
||||
holder.parentNode.removeChild(holder);
|
||||
}
|
||||
holder = null;
|
||||
}
|
||||
|
||||
function onScrollNav() {
|
||||
if (window.innerWidth < DESKTOP_MIN) {
|
||||
unlockNav();
|
||||
return;
|
||||
}
|
||||
if (window.pageYOffset > navBaseTop) {
|
||||
lockNav();
|
||||
} else {
|
||||
unlockNav();
|
||||
}
|
||||
}
|
||||
|
||||
if (nav) {
|
||||
measureNavTop();
|
||||
window.addEventListener('scroll', onScrollNav, { passive: true });
|
||||
var resizeTimer = null;
|
||||
window.addEventListener('resize', function () {
|
||||
if (resizeTimer) { window.clearTimeout(resizeTimer); }
|
||||
resizeTimer = window.setTimeout(function () {
|
||||
measureNavTop();
|
||||
onScrollNav();
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 移动端抽屉 + 二级手风琴 ---------- */
|
||||
var navToggle = document.querySelector('.cz-nav-toggle');
|
||||
var navList = document.querySelector('.cz-nav-list');
|
||||
|
||||
if (navToggle && navList) {
|
||||
navToggle.addEventListener('click', function () {
|
||||
var open = navList.classList.toggle('cz-open');
|
||||
navToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
/* 窄屏下:点击带二级的项先展开面板,再点才跳转;宽屏交还给 CSS hover */
|
||||
if (navList) {
|
||||
navList.addEventListener('click', function (e) {
|
||||
if (window.innerWidth >= DESKTOP_MIN) { return; }
|
||||
var link = e.target.closest ? e.target.closest('a') : null;
|
||||
if (!link) { return; }
|
||||
var li = link.parentElement;
|
||||
if (!li || !li.classList.contains('has-sub')) { return; }
|
||||
var sub = li.querySelector('.cz-sub');
|
||||
if (!sub) { return; }
|
||||
if (!li.classList.contains('cz-sub-open')) {
|
||||
e.preventDefault();
|
||||
li.classList.add('cz-sub-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 首页轮播 ---------- */
|
||||
var root = document.querySelector('.cz-banner');
|
||||
var slides = document.querySelectorAll('.cz-banner-slide');
|
||||
var dots = document.querySelectorAll('.cz-banner-dots button');
|
||||
var prevBtn = document.querySelector('.cz-banner-arrow.prev');
|
||||
var nextBtn = document.querySelector('.cz-banner-arrow.next');
|
||||
if (!slides.length || !root) {
|
||||
return;
|
||||
}
|
||||
var current = 0;
|
||||
var timer = null;
|
||||
var INTERVAL = 5000;
|
||||
|
||||
function show(index) {
|
||||
slides[current].classList.remove('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.remove('active');
|
||||
}
|
||||
current = (index + slides.length) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if (slides.length > 1) {
|
||||
timer = window.setInterval(function () {
|
||||
show(current + 1);
|
||||
}, INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dots.forEach(function (dot, index) {
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
start();
|
||||
});
|
||||
});
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () {
|
||||
show(current - 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
show(current + 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
root.addEventListener('mouseenter', stop);
|
||||
root.addEventListener('mouseleave', start);
|
||||
root.addEventListener('focusin', stop);
|
||||
root.addEventListener('focusout', start);
|
||||
root.setAttribute('tabindex', '-1');
|
||||
root.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
show(current - 1);
|
||||
start();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
show(current + 1);
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
/* 触摸滑动切页 */
|
||||
var touchX = null;
|
||||
root.addEventListener('touchstart', function (e) {
|
||||
touchX = e.changedTouches[0].clientX;
|
||||
stop();
|
||||
}, { passive: true });
|
||||
root.addEventListener('touchend', function (e) {
|
||||
if (touchX === null) { return; }
|
||||
var dx = e.changedTouches[0].clientX - touchX;
|
||||
if (Math.abs(dx) > 40) {
|
||||
show(dx < 0 ? current + 1 : current - 1);
|
||||
}
|
||||
touchX = null;
|
||||
start();
|
||||
}, { passive: true });
|
||||
|
||||
start();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 126 KiB |
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="cz-page cz-container" id="main-content">
|
||||
<div class="cz-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="cz-side-nav-list">
|
||||
<li class="cz-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="cz-page-card">
|
||||
<nav class="cz-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node : ${categoryPath}">
|
||||
<span class="cz-crumb-sep" aria-hidden="true">/</span>
|
||||
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||
</th:block>
|
||||
<span class="cz-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||
<span class="cz-crumb-current">正文</span>
|
||||
</nav>
|
||||
|
||||
<article class="cz-article">
|
||||
<h1 class="cz-article-title" th:text="${article.title}">文章标题</h1>
|
||||
<div class="cz-article-meta">
|
||||
<time th:if="${article.publishTime != null}"
|
||||
th:text="${#temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
|
||||
<span th:if="${!#strings.isEmpty(article.authorName)}"
|
||||
th:text="'作者:' + ${article.authorName}">作者</span>
|
||||
<span th:if="${!#strings.isEmpty(article.source)}"
|
||||
th:text="'来源:' + ${article.source}">来源</span>
|
||||
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
|
||||
</div>
|
||||
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
|
||||
其余字段一律 th:text 转义输出 -->
|
||||
<div class="cz-article-content" th:utext="${article.content}">正文</div>
|
||||
</article>
|
||||
|
||||
<nav class="cz-sibling" aria-label="上下篇导航">
|
||||
<a th:if="${prevArticle != null}" class="cz-sib prev"
|
||||
th:href="${url.article(prevArticle.id)}">
|
||||
<span class="cz-sib-label">上一篇</span>
|
||||
<span class="cz-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
|
||||
</a>
|
||||
<span class="cz-sib-none prev" th:if="${prevArticle == null}">
|
||||
<span class="cz-sib-label">上一篇</span>
|
||||
<span class="cz-sib-title">已经是最早一篇</span>
|
||||
</span>
|
||||
<a th:if="${nextArticle != null}" class="cz-sib next"
|
||||
th:href="${url.article(nextArticle.id)}">
|
||||
<span class="cz-sib-label">下一篇</span>
|
||||
<span class="cz-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
|
||||
</a>
|
||||
<span class="cz-sib-none next" th:if="${nextArticle == null}">
|
||||
<span class="cz-sib-label">下一篇</span>
|
||||
<span class="cz-sib-title">已是最新一篇</span>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- campus-azure 公共页脚:深蓝渐变底 + 同心圆环纹理(CSS 绘制) + 三栏(品牌/联系备案/友链分组) + 底条校名。
|
||||
数据绑定与 default 契约一致(linkGroups/contact/copyright/beian/logo/schName),仅换皮。 -->
|
||||
<footer class="cz-footer" th:fragment="footer">
|
||||
<i class="cz-ring cz-ring--1" aria-hidden="true"></i>
|
||||
<i class="cz-ring cz-ring--2" aria-hidden="true"></i>
|
||||
<i class="cz-ring cz-ring--3" aria-hidden="true"></i>
|
||||
|
||||
<div class="cz-footer-inner">
|
||||
<!-- 品牌栏 -->
|
||||
<div class="cz-footer-brand">
|
||||
<span class="cz-footer-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="cz-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<span class="cz-footer-badge cz-footer-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" focusable="false">
|
||||
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cz-footer-site" th:text="${site.name}">站点名称</span>
|
||||
</div>
|
||||
|
||||
<!-- 联系/版权/备案 -->
|
||||
<div class="cz-footer-info">
|
||||
<p class="cz-footer-line" th:if="${site.config['contact'] != null}">
|
||||
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['contact']}">联系方式</span>
|
||||
</p>
|
||||
<p class="cz-footer-line" th:if="${site.config['copyright'] != null}">
|
||||
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['copyright']}">版权信息</span>
|
||||
</p>
|
||||
<p class="cz-footer-line" th:if="${site.config['beian'] != null}">
|
||||
<i class="cz-dot" aria-hidden="true"></i><span th:text="${site.config['beian']}">备案号</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 友情链接(分组渲染;无分组整栏不渲染) -->
|
||||
<div class="cz-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||
<div class="cz-links-group" th:each="group : ${site.linkGroups}"
|
||||
th:if="${!#lists.isEmpty(group.links)}">
|
||||
<span class="cz-group-name" th:text="${group.groupName}">分组</span>
|
||||
<p class="cz-links-row">
|
||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||
<span th:text="${link.name}">链接</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cz-footer-bottom">
|
||||
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
|
||||
th:text="${site.schName}">学校名称</p>
|
||||
</div>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<!-- campus-azure 公共 head 片段:与 default 契约一致(pageTitle/seo/canonical/og/themeVarsCss/favicon 兜底) -->
|
||||
<head th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle}">页面标题</title>
|
||||
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
||||
th:content="${seoDescription}">
|
||||
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
|
||||
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
|
||||
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
|
||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||
<link rel="icon" type="image/png"
|
||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||
</head>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- campus-azure 公共头部:深蓝顶栏(日期+院系切换) + 深蓝品牌行(徽标+站名+圆角搜索) + 白底主导航(吸顶反蓝、
|
||||
主蓝半透明下拉面板)。数据绑定与 default 契约一致,仅换皮。 -->
|
||||
<header class="cz-header" th:fragment="header">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
|
||||
<!-- 顶栏 -->
|
||||
<div class="cz-topbar">
|
||||
<div class="cz-topbar-inner">
|
||||
<span class="cz-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
|
||||
<div class="cz-topbar-side" th:if="${!site.colleges.isEmpty()}"
|
||||
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
|
||||
<div class="cz-colleges">
|
||||
<span class="cz-colleges-toggle">
|
||||
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
|
||||
<i class="cz-colleges-arrow" aria-hidden="true">▾</i>
|
||||
</span>
|
||||
<ul class="cz-colleges-menu">
|
||||
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
|
||||
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
|
||||
th:title="${col.name}" th:text="${col.name}">学院名称</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品牌行 -->
|
||||
<div class="cz-brandbar">
|
||||
<div class="cz-brandbar-inner">
|
||||
<div class="cz-brand">
|
||||
<span class="cz-brand-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="cz-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<span class="cz-brand-badge cz-brand-badge--plain" th:if="${site.config['logo'] == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
|
||||
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<a class="cz-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
|
||||
</div>
|
||||
<form class="cz-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">
|
||||
<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>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主导航:白底深蓝字;下拉为主蓝半透明面板(.cz-sub 展开动效见 main.css);吸顶反蓝由 main.js 处理 -->
|
||||
<nav class="cz-nav" id="czNav" aria-label="栏目导航">
|
||||
<div class="cz-nav-inner">
|
||||
<button class="cz-nav-toggle" type="button" aria-label="展开菜单" aria-expanded="false">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<ul class="cz-nav-list">
|
||||
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
</li>
|
||||
<li th:each="cat : ${navCategories}"
|
||||
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
|
||||
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${cat.name}">栏目</a>
|
||||
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="cz-caret" aria-hidden="true">▾</i></a>
|
||||
<!-- 二级下拉:主蓝半透明面板,顶部亮线,项 hover 加深+左亮条 -->
|
||||
<ul class="cz-sub" th:if="${!cat.children.isEmpty()}">
|
||||
<li th:each="sub : ${cat.children}"
|
||||
th:classappend="${sub.id == currentCategoryId} ? 'active'">
|
||||
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${sub.name}">子栏目</a>
|
||||
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
</html>
|
||||
@@ -0,0 +1,209 @@
|
||||
<!--
|
||||
campus-azure(青蓝校园)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
|
||||
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
|
||||
结构复用主题 main.css 第 7/9 节既有语汇(cz-block / cz-block-head / cz-block-item / cz-feat-* /
|
||||
cz-top-badge / cz-empty),布局模式特有的排布(封面头条、圆点行、图墙、速递条、链接行)另起第 16 节。
|
||||
与 academic-navy 的分野:区块头用居中双线(师范/农林官网语言)、要闻用全宽封面头条、列表用圆点密行。
|
||||
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background)。
|
||||
-->
|
||||
<!-- 形态一:图文要闻(homeStyle=news_grid)——全宽封面头条(遮罩标题)+ 双列密行 -->
|
||||
<section class="cz-section cz-container" th:fragment="news_grid(block)" aria-label="栏目区块"
|
||||
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
|
||||
<div class="cz-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
|
||||
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
|
||||
<div class="cz-block cz-block--flow"
|
||||
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
|
||||
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.category.name}">校园要闻</h2>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<a class="cz-hero" th:if="${lead != null}" th:href="${url.article(lead.id)}">
|
||||
<img th:if="${lead.coverImage != null}" th:src="${url.upload(lead.coverImage)}"
|
||||
th:alt="${lead.title}" loading="lazy">
|
||||
<span class="cz-hero-plain" th:if="${lead.coverImage == null}" aria-hidden="true"></span>
|
||||
<span class="cz-hero-mask">
|
||||
<span class="cz-hero-title" th:text="${lead.title}">头条标题</span>
|
||||
<span class="cz-hero-meta">
|
||||
<span class="cz-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="cz-block-list cz-block-list--two" th:if="${rest != null}">
|
||||
<li class="cz-block-item" th:each="item : ${rest}">
|
||||
<i class="cz-item-bullet" aria-hidden="true"></i>
|
||||
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="cz-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态二:默认卡(homeStyle 未设/未知)——左图文头条 + 右圆点密行 -->
|
||||
<section class="cz-section cz-container" th:fragment="card(block)" aria-label="栏目区块">
|
||||
<div class="cz-block cz-block--flow">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="cz-split" th:if="${!#lists.isEmpty(block.items)}"
|
||||
th:with="feat=${block.items[0]}, rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
|
||||
<div class="cz-block-featured">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="cz-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="cz-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="cz-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
|
||||
<span class="cz-feat-meta">
|
||||
<span class="cz-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="cz-block-list" th:if="${rest != null}">
|
||||
<li class="cz-block-item" th:each="item : ${rest}">
|
||||
<i class="cz-item-bullet" aria-hidden="true"></i>
|
||||
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="cz-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态三:圆点密行(homeStyle=compact)——官网最密的「圆点 + 标题 + 右日期」单行 -->
|
||||
<section class="cz-section cz-container" th:fragment="compact(block)" aria-label="栏目区块">
|
||||
<div class="cz-block cz-block--flow">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.category.name}">通知公告</h2>
|
||||
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ul class="cz-block-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="cz-block-item" th:each="item : ${block.items}">
|
||||
<i class="cz-item-bullet" aria-hidden="true"></i>
|
||||
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="cz-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态四:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列密行 -->
|
||||
<section class="cz-section cz-container" th:fragment="media_focus(block)" aria-label="栏目区块">
|
||||
<div class="cz-block cz-block--flow">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.category.name}">媒体聚焦</h2>
|
||||
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ul class="cz-block-list cz-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="cz-block-item" th:each="item : ${block.items}">
|
||||
<span class="cz-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
|
||||
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<time class="cz-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态五:图片墙(homeStyle=image_wall)——仅取有封面的条目,三列大格 -->
|
||||
<section class="cz-section cz-container" th:fragment="image_wall(block)" aria-label="栏目区块">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.category.name}">图片新闻</h2>
|
||||
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ul class="cz-wall" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="cz-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="cz-wall-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/campus-azure/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
|
||||
<section class="cz-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="cz-container cz-ticker-inner">
|
||||
<span class="cz-ticker-label" th:text="${block.title}">要闻速递</span>
|
||||
<ul class="cz-ticker-list">
|
||||
<li th:each="item : ${block.items}">
|
||||
<a class="cz-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:线上展厅(block.items = 轮播位 gallery) -->
|
||||
<section class="cz-section cz-container cz-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.title}">线上展厅</h2>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p class="cz-gallery-sub" th:if="${!#strings.isEmpty(block.subtitle)}" th:text="${block.subtitle}">
|
||||
Online Exhibition Hall
|
||||
</p>
|
||||
<ul class="cz-wall cz-wall--4">
|
||||
<li class="cz-wall-item" th:each="g : ${block.items}">
|
||||
<a th:href="${g.linkUrl}" th:title="${g.title}">
|
||||
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
|
||||
<span class="cz-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:友情链接分组(block.linkGroups 由渲染端按 group 过滤) -->
|
||||
<section class="cz-section cz-container" th:fragment="links(block)" aria-label="友情链接">
|
||||
<div class="cz-block cz-block--flow">
|
||||
<div class="cz-block-head">
|
||||
<h2 th:text="${block.title}">友情链接</h2>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<th:block th:each="grp : ${block.linkGroups}">
|
||||
<p class="cz-links-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
|
||||
<ul class="cz-links">
|
||||
<li th:each="l : ${grp.links}">
|
||||
<a th:href="${l.linkUrl}" th:text="${l.name}" target="_blank" rel="noopener">链接名</a>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
|
||||
<div class="cz-empty" th:fragment="empty(block)">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="cz-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="cz-home" id="main-content">
|
||||
<!-- 首页轮播位(cms_banner position=home;设计尺寸见 theme.json bannerSize) -->
|
||||
<section class="cz-banner" th:if="${!#lists.isEmpty(banners)}" aria-label="焦点图片">
|
||||
<div class="cz-banner-slides">
|
||||
<a class="cz-banner-slide" th:each="b, stat : ${banners}"
|
||||
th:classappend="${stat.first} ? 'active'"
|
||||
th:href="${b.linkUrl}" th:title="${b.title}">
|
||||
<img th:src="${b.imageUrl}" th:alt="${b.title}">
|
||||
<span class="cz-banner-title" th:text="${b.title}">标题</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="cz-banner-arrow prev" type="button" aria-label="上一张" th:if="${#lists.size(banners) > 1}">‹</button>
|
||||
<button class="cz-banner-arrow next" type="button" aria-label="下一张" th:if="${#lists.size(banners) > 1}">›</button>
|
||||
<div class="cz-banner-dots" th:if="${#lists.size(banners) > 1}">
|
||||
<button th:each="b, stat : ${banners}" th:classappend="${stat.first} ? 'active'"
|
||||
th:aria-label="${'第' + (stat.count) + '张'}"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):区块形态由本主题 fragments/home-blocks 承载——换肤必须连首页正文一起换,
|
||||
只换页头页脚会被判「皮肤不生效」。未知类型仍回落 common 骨架(渲染端已过滤未知 type,正常不触发)。
|
||||
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
|
||||
<th:block th:if="${layoutBlocks != null}">
|
||||
<th:block th:each="block : ${layoutBlocks}">
|
||||
<th:block th:switch="${block.type}">
|
||||
<th:block th:case="'article_list'">
|
||||
<th:block th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
|
||||
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
<!-- 回落分支(docs/16 D2):无 home_layout 时按现状渲染 -->
|
||||
<th:block th:unless="${layoutBlocks != null}">
|
||||
<section class="cz-section cz-container" aria-label="头条与图片新闻">
|
||||
<div class="cz-headline">
|
||||
<div class="cz-headline-card" th:if="${!#lists.isEmpty(topArticles)}">
|
||||
<div class="cz-block-head">
|
||||
<h2>头条推荐</h2>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<ul class="cz-headline-list">
|
||||
<li class="cz-headline-lead" th:with="lead=${topArticles[0]}">
|
||||
<a class="cz-lead-title" th:href="${url.article(lead.id)}" th:text="${lead.title}">头条标题</a>
|
||||
<div class="cz-lead-meta">
|
||||
<span class="cz-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</li>
|
||||
<li th:each="item, stat : ${topArticles}" th:if="${stat.index > 0}">
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cz-focus-card" th:if="${!#lists.isEmpty(focusArticles)}">
|
||||
<div class="cz-block-head">
|
||||
<h2>图片新闻</h2>
|
||||
<i class="cz-block-tick" aria-hidden="true"></i>
|
||||
</div>
|
||||
<a class="cz-focus-lead" th:with="cover=${focusArticles[0]}"
|
||||
th:href="${url.article(cover.id)}">
|
||||
<span class="cz-focus-cover">
|
||||
<img th:src="${url.upload(cover.coverImage)}" th:alt="${cover.title}">
|
||||
</span>
|
||||
<span class="cz-focus-title" th:text="${cover.title}">图片新闻标题</span>
|
||||
</a>
|
||||
<ul class="cz-focus-rows">
|
||||
<li th:each="row, stat : ${focusArticles}" th:if="${stat.index > 0}">
|
||||
<a th:href="${url.article(row.id)}">
|
||||
<span class="cz-row-cover"><img th:src="${url.upload(row.coverImage)}" th:alt="${row.title}"></span>
|
||||
<span class="cz-row-title" th:text="${row.title}">图片新闻标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 一级栏目区块(聚合列表型子孙栏目 top 8) -->
|
||||
<section class="cz-section cz-container" aria-label="栏目区块">
|
||||
<div class="cz-blocks">
|
||||
<div class="cz-block" th:each="block : ${categoryBlocks}">
|
||||
<div class="cz-block-head cz-block-head--bar">
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
<a class="cz-more" th:href="${block.moreUrl}">更多 ›</a>
|
||||
</div>
|
||||
<ul class="cz-block-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<!-- 区块头条:有封面则图文,无封面则带摘要纯文 -->
|
||||
<li class="cz-block-featured" th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="cz-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}">
|
||||
</span>
|
||||
<span class="cz-feat-body">
|
||||
<span class="cz-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="cz-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}"
|
||||
th:text="${feat.summary}">摘要</span>
|
||||
<span class="cz-feat-meta">
|
||||
<span class="cz-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="cz-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<span class="cz-date-chip" th:if="${item.publishTime != null}"
|
||||
th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="cz-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cz-empty" th:if="${#lists.isEmpty(block.items)}">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="cz-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</th:block>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="cz-page cz-container" id="main-content">
|
||||
<div class="cz-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="cz-side-nav-list">
|
||||
<li class="cz-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="cz-page-card">
|
||||
<nav class="cz-crumb" aria-label="面包屑" th:if="${searchMode != true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node, st : ${categoryPath}">
|
||||
<span class="cz-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
|
||||
th:text="${node.name}">父栏目</a>
|
||||
<span class="cz-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
|
||||
</th:block>
|
||||
</nav>
|
||||
<nav class="cz-crumb" aria-label="面包屑" th:if="${searchMode == true}">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="cz-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="cz-crumb-current">搜索结果</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="cz-page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
|
||||
<h1 class="cz-page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
|
||||
|
||||
<ul class="cz-article-list">
|
||||
<!-- 有封面:图文行;无封面:行式(符号 + 标题 + 右侧日期) -->
|
||||
<li th:each="item : ${listData.items}">
|
||||
<div class="cz-article-row" th:if="${item.coverImage != null}">
|
||||
<a class="cz-item-cover" th:href="${url.article(item.id)}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}">
|
||||
</a>
|
||||
<div class="cz-item-body">
|
||||
<div class="cz-item-title">
|
||||
<a th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
</div>
|
||||
<p class="cz-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
<div class="cz-item-meta">
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cz-article-line" th:if="${item.coverImage == null}">
|
||||
<i class="cz-line-tick" aria-hidden="true"></i>
|
||||
<div class="cz-line-main">
|
||||
<a class="cz-line-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<p class="cz-item-summary" th:if="${!#strings.isEmpty(item.summary)}"
|
||||
th:text="${item.summary}">摘要</p>
|
||||
</div>
|
||||
<div class="cz-line-side">
|
||||
<span class="cz-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cz-empty" th:if="${#lists.isEmpty(listData.items)}">
|
||||
<p th:if="${searchMode == true}">未找到相关内容</p>
|
||||
<p th:if="${searchMode != true}">栏目暂无内容</p>
|
||||
<p class="cz-empty-hint" th:if="${searchMode == true}">换个关键词试试</p>
|
||||
<p class="cz-empty-hint" th:if="${searchMode != true}">内容发布后将在此展示</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页(列表页/搜索页共用) -->
|
||||
<nav class="cz-pagination" th:if="${listData.totalPages > 1}"
|
||||
th:with="cur=${listData.page}, total=${listData.totalPages}" aria-label="分页">
|
||||
<a th:classappend="${cur <= 1} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (cur - 1)}"
|
||||
th:aria-disabled="${cur <= 1}">上一页</a>
|
||||
<a th:each="p : ${#numbers.sequence(1, total)}"
|
||||
th:classappend="${p == cur} ? 'active'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, p)} : ${url.category(listData.categoryId) + '?page=' + p}"
|
||||
th:aria-label="${'第' + p + '页'}"
|
||||
th:text="${p}">1</a>
|
||||
<a th:classappend="${cur >= total} ? 'disabled'"
|
||||
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}"
|
||||
th:aria-disabled="${cur >= total}">下一页</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="cz-page cz-container" id="main-content">
|
||||
<div class="cz-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="cz-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="cz-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="cz-side-nav-list">
|
||||
<li class="cz-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="cz-page-card">
|
||||
<nav class="cz-crumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node, st : ${categoryPath}">
|
||||
<span class="cz-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:if="${!st.last}" class="crumb-parent" th:href="${url.category(node.id)}"
|
||||
th:text="${node.name}">父栏目</a>
|
||||
<span class="cz-crumb-current" th:if="${st.last}" th:text="${node.name}">栏目</span>
|
||||
</th:block>
|
||||
</nav>
|
||||
|
||||
<article class="cz-article">
|
||||
<h1 class="cz-article-title" th:text="${category.name}">栏目名</h1>
|
||||
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
|
||||
<div class="cz-article-content" th:if="${!#strings.isEmpty(category.content)}"
|
||||
th:utext="${category.content}">单页内容</div>
|
||||
<div class="cz-empty" th:if="${#strings.isEmpty(category.content)}">
|
||||
<p>页面暂无内容</p>
|
||||
<p class="cz-empty-hint">内容编辑后将在此展示</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"specVersion": 1,
|
||||
"name": "campus-azure",
|
||||
"title": "青蓝校园(campus-azure)",
|
||||
"version": "1.0.0",
|
||||
"author": "yunhai",
|
||||
"description": "青蓝校园·师范/农林类官网门户:青蓝顶栏+白底主导航+青绿描边标题+大日期数字列表+图文左列右栏+圆角卡片区块+深色页脚带二维码位。基座 scholar-blue,配色与首页区块形态按师范/农林类高校官网重做。",
|
||||
"screenshot": "assets/screenshot.png",
|
||||
"bannerSize": "1920x560",
|
||||
"params": [
|
||||
{ "key": "primary", "cssVar": "--cz-primary", "label": "主色", "type": "color", "default": "#0d6a9e" },
|
||||
{ "key": "primaryDark", "cssVar": "--cz-primary-dark", "label": "主色加深", "type": "color", "default": "#084c73" },
|
||||
{ "key": "primaryLight", "cssVar": "--cz-primary-light","label": "主色提亮", "type": "color", "default": "#1d87bd" },
|
||||
{ "key": "accent", "cssVar": "--cz-accent", "label": "点缀青绿", "type": "color", "default": "#2b9c8f" },
|
||||
{ "key": "navBg", "cssVar": "--cz-nav-bg", "label": "导航条底色", "type": "color", "default": "#ffffff" },
|
||||
{ "key": "footerBg", "cssVar": "--cz-footer-bg", "label": "页脚底色", "type": "color", "default": "#0b3348" },
|
||||
{ "key": "text", "cssVar": "--cz-text", "label": "正文文字色", "type": "color", "default": "#303c46" },
|
||||
{ "key": "textLight", "cssVar": "--cz-text-light", "label": "次要文字色", "type": "color", "default": "#6e7d88" },
|
||||
{ "key": "bg", "cssVar": "--cz-bg", "label": "页面底色", "type": "color", "default": "#eef3f6" },
|
||||
{ "key": "cardBg", "cssVar": "--cz-card-bg", "label": "卡片底色", "type": "color", "default": "#ffffff" },
|
||||
{ "key": "border", "cssVar": "--cz-border", "label": "边框色", "type": "color", "default": "#d9e5ec" }
|
||||
]
|
||||
}
|
||||
@@ -400,3 +400,257 @@ button { font-family: inherit; cursor: pointer; }
|
||||
.cg-gallery-item img { height: 230px; }
|
||||
.cg-qr:hover img { transform: scale(1.6); }
|
||||
}
|
||||
|
||||
|
||||
/* =====================================================================
|
||||
首页布局模式区块补齐(v2 手册 §A.3 · P-A3,S7)
|
||||
对应 fragments/home-blocks.html 新增的 5 个 fragment:compact / image_wall / links / quick_links / notice_bar。
|
||||
本包三条硬约束说明:
|
||||
· §2.5 坑③(全宽区块根补 grid-column)在本包**不适用**:.cg-home 无任何布局规则(非网格),
|
||||
区块用"全幅 section > .cg-container"惯用结构天然占满整行——补 grid-column 只会留死声明。
|
||||
· 网格轨道一律 minmax(0,1fr)(本包 .cg-news-grid/.cg-media-grid 基线用的是裸 1fr,
|
||||
新节按记忆教训改用 minmax,避免中文 min-content 撑破窄视口;不动既有两行以守住"已有形态不改")。
|
||||
· 颜色只用本包既有令牌;#fff 与 color-mix() 是本包既有习惯(顶栏/导航/侧栏已如此写),未新增色值。
|
||||
===================================================================== */
|
||||
|
||||
/* 区块壳:与 .cg-block-card / .cg-media-card 同值(本包习惯是各形态各写一份,不合并选择器) */
|
||||
.cg-compact, .cg-wall, .cg-friend, .cg-quick { padding: 40px 0 0; }
|
||||
.cg-sec-card {
|
||||
background: var(--cg-card-bg); border: 1px solid var(--cg-border);
|
||||
border-radius: var(--cg-radius); box-shadow: var(--cg-shadow); padding: 26px 30px;
|
||||
}
|
||||
|
||||
/* 形态四 compact:圆角日期块 + 单行标题 */
|
||||
.cg-compact-list { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; }
|
||||
.cg-compact-list li {
|
||||
display: flex; align-items: center; gap: 14px; min-width: 0;
|
||||
padding: 11px 2px; border-bottom: 1px dashed var(--cg-border);
|
||||
}
|
||||
.cg-compact-list li:nth-last-child(-n+2) { border-bottom: 0; }
|
||||
.cg-date-chip {
|
||||
flex: none; display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
width: 56px; line-height: 1.25; color: #fff; border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--cg-primary), var(--cg-primary-dark));
|
||||
box-shadow: 0 3px 8px rgba(10, 98, 68, .22);
|
||||
}
|
||||
.cg-date-chip b { font-size: 20px; font-weight: 700; }
|
||||
.cg-date-chip i { font-style: normal; font-size: 11px; opacity: .82; letter-spacing: .5px; }
|
||||
.cg-compact-title {
|
||||
flex: 1; min-width: 0; font-size: 15px; color: var(--cg-text);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.cg-compact-title:hover { color: var(--cg-primary); }
|
||||
|
||||
/* 形态五 image_wall:四列图墙 + 底部渐变题注(渐变写法同 .cg-gallery-cap,色值为该规则既有) */
|
||||
.cg-wall-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.cg-wall-item { position: relative; border-radius: 10px; overflow: hidden; }
|
||||
.cg-wall-item a { display: block; }
|
||||
.cg-wall-item img {
|
||||
width: 100%; height: 156px; object-fit: cover;
|
||||
transition: transform .3s ease, box-shadow .3s ease;
|
||||
}
|
||||
.cg-wall-item:hover img { transform: translateY(-4px); box-shadow: 0 14px 28px rgba(10, 98, 68, .22); }
|
||||
.cg-wall-cap {
|
||||
position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 12px 9px;
|
||||
color: #fff; font-size: 14px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .55));
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 形态六 links:友情链接分组(分组标题带强调色竖条;logo 用页脚同款 .cg-link-logo) */
|
||||
.cg-friend-group {
|
||||
margin: 16px 0 8px; padding-left: 10px; font-size: 14px; font-weight: 600;
|
||||
color: var(--cg-primary-dark); border-left: 3px solid var(--cg-accent);
|
||||
}
|
||||
.cg-friend-group:first-of-type { margin-top: 0; }
|
||||
.cg-friend-links { display: flex; flex-wrap: wrap; gap: 8px 12px; }
|
||||
.cg-friend-links li { flex: none; }
|
||||
.cg-friend-links a {
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 5px 14px;
|
||||
font-size: 13px; color: var(--cg-text); background: var(--cg-bg);
|
||||
border: 1px solid var(--cg-border); border-radius: 999px; transition: color .2s, border-color .2s;
|
||||
}
|
||||
.cg-friend-links a:hover { color: var(--cg-primary); border-color: var(--cg-primary); }
|
||||
|
||||
/* 新区块一 quick_links:六列宫格 → 三列 → 两列(入口名居中,悬停走本包渐变语言) */
|
||||
.cg-quick-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
|
||||
.cg-quick-link {
|
||||
display: flex; align-items: center; justify-content: center; min-height: 58px; padding: 8px 10px;
|
||||
font-size: 14px; text-align: center; color: var(--cg-text);
|
||||
background: var(--cg-bg); border: 1px solid var(--cg-border); border-radius: 10px;
|
||||
transition: color .2s, background .2s, transform .2s, box-shadow .2s;
|
||||
}
|
||||
.cg-quick-link:hover {
|
||||
color: #fff; background: linear-gradient(135deg, var(--cg-primary), var(--cg-primary-dark));
|
||||
border-color: var(--cg-primary); transform: translateY(-3px); box-shadow: var(--cg-shadow);
|
||||
}
|
||||
|
||||
/* 新区块二 notice_bar:整幅横条(与 .cg-ticker 同层级,不是卡片)。
|
||||
info 用主色浅底、warn 用强调色浅底——本包有 --cg-accent 令牌,故不需要像 mint-breeze 那样自持 warn 三件套。 */
|
||||
.cg-notice { border-top: 1px solid var(--cg-border); border-bottom: 1px solid var(--cg-border); background: var(--cg-card-bg); }
|
||||
.cg-notice-inner { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding-top: 12px; padding-bottom: 12px; font-size: 14px; }
|
||||
.cg-notice-tag {
|
||||
flex: none; font-size: 13px; font-weight: 600; color: #fff; padding: 4px 14px; border-radius: 0 0 10px 10px;
|
||||
background: linear-gradient(135deg, var(--cg-primary), var(--cg-primary-dark));
|
||||
}
|
||||
.cg-notice-link, .cg-notice-text { flex: 1; min-width: 0; color: var(--cg-text); }
|
||||
.cg-notice-link { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cg-notice-link:hover { color: var(--cg-primary); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cg-notice-info { border-top-color: color-mix(in srgb, var(--cg-primary) 30%, var(--cg-border)); background: color-mix(in srgb, var(--cg-primary) 7%, var(--cg-card-bg)); }
|
||||
.cg-notice-warn { border-top-color: color-mix(in srgb, var(--cg-accent) 45%, var(--cg-border)); background: color-mix(in srgb, var(--cg-accent) 9%, var(--cg-card-bg)); }
|
||||
.cg-notice-warn .cg-notice-tag { background: linear-gradient(135deg, var(--cg-accent), #ff9f4d); }
|
||||
.cg-notice-warn .cg-notice-link:hover { color: var(--cg-accent); }
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.cg-sec-card { padding: 20px 18px; }
|
||||
.cg-wall-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cg-quick-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.cg-compact-list { grid-template-columns: 1fr; }
|
||||
.cg-compact-list li:nth-last-child(-n+2) { border-bottom: 1px dashed var(--cg-border); }
|
||||
.cg-compact-list li:last-child { border-bottom: 0; }
|
||||
.cg-compact, .cg-wall, .cg-friend, .cg-quick { padding-top: 26px; }
|
||||
.cg-quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cg-notice-inner { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
.cg-notice-link { white-space: normal; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
服务中心 / 事项指南(v2 手册 §A.3 · P-A3,S7 新建 service.html + guide.html)
|
||||
页壳、面包屑、页题、分页、空态、标题/元信息/正文全部复用本包既有
|
||||
.cg-list-main / .cg-page-card / .cg-breadcrumb / .cg-page-title / .cg-pagination /
|
||||
.cg-empty / .cg-article-title / .cg-article-meta / .cg-article-content,
|
||||
本节只补事项特有的件;色值只用本包令牌(#fff、#ff9f4d 为本包既有习惯)。
|
||||
===================================================================== */
|
||||
|
||||
/* 筛选面板:分组行 + 胶囊选项(active 走本包主色渐变) */
|
||||
.cg-svc-filters {
|
||||
border: 1px solid var(--cg-border); border-radius: 10px;
|
||||
background: var(--cg-bg); padding: 8px 14px; margin-bottom: 18px;
|
||||
}
|
||||
.cg-svc-filter-group { display: flex; align-items: flex-start; gap: 12px; padding: 7px 0; }
|
||||
.cg-svc-filter-group + .cg-svc-filter-group { border-top: 1px dashed var(--cg-border); }
|
||||
.cg-svc-filter-label { flex: none; min-width: 60px; font-size: 13px; font-weight: 600; color: var(--cg-primary-dark); padding-top: 3px; }
|
||||
.cg-svc-filter-options { display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; }
|
||||
.cg-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; padding: 2px 12px;
|
||||
font-size: 13px; color: var(--cg-text-light);
|
||||
background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: 999px;
|
||||
transition: color .2s, border-color .2s, background .2s;
|
||||
}
|
||||
.cg-chip em { font-style: normal; font-size: 12px; opacity: .75; }
|
||||
.cg-chip:hover { color: var(--cg-primary); border-color: var(--cg-primary); }
|
||||
.cg-chip.active {
|
||||
color: #fff; border-color: var(--cg-primary);
|
||||
background: linear-gradient(135deg, var(--cg-primary), var(--cg-primary-dark));
|
||||
}
|
||||
|
||||
/* 工具条:搜索框(圆角同 .cg-site-search,但方角贴合卡片)+ 清除筛选 */
|
||||
.cg-svc-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
|
||||
.cg-svc-search { display: flex; flex: 1; min-width: 0; }
|
||||
.cg-svc-search input {
|
||||
flex: 1; min-width: 0; padding: 9px 14px; font-size: 14px; font-family: inherit;
|
||||
border: 1px solid var(--cg-border); border-right: 0; border-radius: 8px 0 0 8px;
|
||||
background: var(--cg-bg); color: var(--cg-text); outline: none;
|
||||
}
|
||||
.cg-svc-search input:focus { border-color: var(--cg-primary); background: var(--cg-card-bg); }
|
||||
.cg-svc-search button {
|
||||
flex: none; padding: 9px 24px; border: 0; border-radius: 0 8px 8px 0;
|
||||
background: var(--cg-primary); color: #fff; font-size: 14px; transition: background .2s;
|
||||
}
|
||||
.cg-svc-search button:hover { background: var(--cg-primary-dark); }
|
||||
.cg-svc-clear { flex: none; font-size: 13px; color: var(--cg-primary); }
|
||||
.cg-svc-clear:hover { color: var(--cg-accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* 事项卡网格:三列 → 两列 → 单列;轨道 minmax(0,·) 防中文 min-content 撑破窄视口 */
|
||||
.cg-svc-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.cg-svc-item {
|
||||
display: flex; gap: 14px; align-items: flex-start; min-width: 0; padding: 16px;
|
||||
background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: 10px;
|
||||
transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease;
|
||||
}
|
||||
.cg-svc-item:hover { transform: translateY(-3px); border-color: var(--cg-primary); box-shadow: var(--cg-shadow); }
|
||||
.cg-svc-item-cover { flex: none; width: 108px; border-radius: 8px; overflow: hidden; }
|
||||
.cg-svc-item-cover img { width: 100%; height: 72px; object-fit: cover; }
|
||||
.cg-svc-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.cg-svc-item-title { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
|
||||
.cg-svc-item-name {
|
||||
font-size: 15px; font-weight: 600; color: var(--cg-text); line-height: 1.5;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.cg-svc-item:hover .cg-svc-item-name { color: var(--cg-primary); }
|
||||
.cg-svc-item-summary {
|
||||
font-size: 13px; color: var(--cg-text-light);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.cg-svc-item-meta { margin-top: auto; display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; color: var(--cg-text-light); }
|
||||
|
||||
/* 标签:办理方式用主色浅底;"可在线办理"是第二优先级信息,用强调色描边区分而不是引入新色相 */
|
||||
.cg-tag {
|
||||
flex: none; font-size: 12px; line-height: 1.6; padding: 1px 9px; border-radius: 4px;
|
||||
color: var(--cg-primary); background: color-mix(in srgb, var(--cg-primary) 10%, var(--cg-card-bg));
|
||||
}
|
||||
.cg-tag.online { color: var(--cg-accent); background: transparent; border: 1px solid color-mix(in srgb, var(--cg-accent) 55%, transparent); }
|
||||
|
||||
/* 指南页:办理按钮、基本信息表、章节题、附件、跳转条、相关服务 */
|
||||
.cg-svc-apply { display: flex; align-items: center; gap: 14px; margin: 18px 0; }
|
||||
.cg-svc-apply-btn {
|
||||
display: inline-block; padding: 10px 32px; font-size: 15px; color: #fff;
|
||||
border-radius: 999px; background: linear-gradient(135deg, var(--cg-primary), var(--cg-primary-dark));
|
||||
box-shadow: 0 6px 16px rgba(10, 98, 68, .26); transition: transform .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.cg-svc-apply-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 22px rgba(10, 98, 68, .34); }
|
||||
.cg-svc-apply-hint { font-size: 13px; color: var(--cg-text-light); }
|
||||
.cg-svc-summary { font-size: 15px; color: var(--cg-text-light); line-height: 1.85; margin-bottom: 18px; }
|
||||
|
||||
.cg-svc-info { width: 100%; border-collapse: collapse; margin-bottom: 18px; font-size: 14px; }
|
||||
.cg-svc-info th, .cg-svc-info td { border: 1px solid var(--cg-border); padding: 10px 14px; text-align: left; vertical-align: top; }
|
||||
.cg-svc-info th { width: 132px; font-weight: 500; color: var(--cg-text-light); background: var(--cg-bg); }
|
||||
|
||||
.cg-svc-section { margin-top: 22px; }
|
||||
.cg-svc-section h2 {
|
||||
font-size: 18px; font-weight: 600; color: var(--cg-primary-dark);
|
||||
padding-left: 12px; margin-bottom: 10px; border-left: 4px solid var(--cg-accent);
|
||||
}
|
||||
.cg-svc-section .cg-article-content { padding-top: 0; }
|
||||
.cg-svc-attachments > ul { display: flex; flex-direction: column; gap: 8px; }
|
||||
.cg-svc-attachments a { font-size: 14px; color: var(--cg-primary-dark); }
|
||||
.cg-svc-attachments a:hover { color: var(--cg-primary); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cg-svc-att-size { margin-left: 8px; font-size: 12px; color: var(--cg-text-light); }
|
||||
|
||||
.cg-svc-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 24px; padding-top: 18px; border-top: 1px dashed var(--cg-border); }
|
||||
.cg-svc-links a {
|
||||
padding: 4px 18px; font-size: 14px; color: var(--cg-primary);
|
||||
border: 1px solid var(--cg-primary); border-radius: 999px; transition: color .2s, background .2s;
|
||||
}
|
||||
.cg-svc-links a:hover { color: #fff; background: var(--cg-primary); }
|
||||
|
||||
.cg-svc-related { margin-top: 30px; padding: 18px 20px; border: 1px solid var(--cg-border); border-radius: 10px; background: var(--cg-bg); }
|
||||
.cg-svc-related-title { font-size: 17px; font-weight: 600; color: var(--cg-primary-dark); margin-bottom: 6px; }
|
||||
.cg-svc-related-list > li + li { border-top: 1px dashed var(--cg-border); }
|
||||
.cg-svc-related-list a { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; min-width: 0; }
|
||||
.cg-svc-related-name { flex: 1; min-width: 0; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.cg-svc-related-list a:hover .cg-svc-related-name { color: var(--cg-primary); }
|
||||
.cg-svc-related-meta { flex: none; display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--cg-text-light); }
|
||||
|
||||
/* 表格 caption 仅面向读屏(本包 .cg-skip-link 是跳转链接专用,语义不同,不复用) */
|
||||
.cg-visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.cg-svc-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.cg-svc-list { grid-template-columns: minmax(0, 1fr); }
|
||||
.cg-svc-toolbar { flex-direction: column; align-items: stretch; }
|
||||
.cg-svc-clear { align-self: flex-start; }
|
||||
.cg-svc-info th { width: 92px; padding: 9px 10px; }
|
||||
.cg-svc-info td { padding: 9px 10px; }
|
||||
.cg-svc-item { flex-direction: column; }
|
||||
.cg-svc-item-cover { width: 100%; }
|
||||
.cg-svc-item-cover img { height: 158px; }
|
||||
.cg-svc-apply { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
|
||||
@@ -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="栏目导航">
|
||||
|
||||
@@ -148,3 +148,133 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 以下 5 个 fragment 为 v2 手册 §A.3 · P-A3 补齐(S7)=====
|
||||
补齐原因:本包原先只有 5 个自持形态,compact/image_wall/links 三处直接借 themes/common 的 plb 骨架
|
||||
(首页换肤只换页头页脚、正文落骨架 = 手册 §2.2 线上事故根因),quick_links/notice_bar 两个新区块类型则完全没有。
|
||||
上面 5 个形态保持原样未重写;本段沿用本包既有惯例:
|
||||
· 结构 = 全幅 <section class="cg-形态名"> > .cg-container > 卡本体(故本包 main 不是网格,§2.5 坑③ 不适用,见 audit-A.md A3 证据)
|
||||
· 空态 = 内联 <div class="cg-empty"> + th:if 同元素(本包 news_grid / media_focus / card 三形态既有写法如此;不引用 :: empty fragment,
|
||||
因而不新增 fragment 名,S4 门禁 validateHomeBlockFragmentRefs 的"引用 ⊆ 声明"仍成立);
|
||||
links 与 scholar-blue / mint-breeze 定型同构(分组遍历,不设空态卡);notice_bar 无文本时整条隐藏(本包 ticker / gallery 既有"无数据即隐藏"写法)
|
||||
· 可选 Map 键一律 block['key'] 括号访问(坑②)
|
||||
-->
|
||||
<!-- 形态四:日期方块行(homeStyle=compact)——圆角日期块 + 单行标题(原借 common :: plbCompact) -->
|
||||
<section class="cg-compact" th:fragment="compact(block)" aria-label="栏目区块">
|
||||
<div class="cg-container">
|
||||
<div class="cg-sec-card">
|
||||
<div class="cg-sec-head">
|
||||
<h2 th:text="${block.category.name}">通知公告</h2>
|
||||
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="cg-compact-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li th:each="item : ${block.items}">
|
||||
<span class="cg-date-chip" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="cg-compact-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="cg-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="cg-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态五:图片墙(homeStyle=image_wall)——四列格 + 底部渐变题注,仅取有封面的条目(原借 common :: plbImageWall) -->
|
||||
<section class="cg-wall" th:fragment="image_wall(block)" aria-label="栏目区块">
|
||||
<div class="cg-container">
|
||||
<div class="cg-sec-card">
|
||||
<div class="cg-sec-head">
|
||||
<h2 th:text="${block.category.name}">图片新闻</h2>
|
||||
<a class="cg-more-circle" th:href="${block.moreUrl}" aria-label="更多">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" focusable="false" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" d="M5 12h14M13 6l6 6-6 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="cg-wall-grid" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="cg-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="cg-wall-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cg-empty" th:if="${#lists.isEmpty(block.items)}">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="cg-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态六:友情链接分组(type=links,block.linkGroups 由渲染端按 group 过滤)——原借 common :: plbLinks。
|
||||
语义与 quick_links 分开:links 读友情链接表(带 logo),quick_links 读区块内联数组(手册 §2.8 类型 A)。 -->
|
||||
<section class="cg-friend" th:fragment="links(block)" aria-label="友情链接">
|
||||
<div class="cg-container">
|
||||
<div class="cg-sec-card">
|
||||
<div class="cg-sec-head">
|
||||
<h2 th:text="${block.title}">友情链接</h2>
|
||||
</div>
|
||||
<th:block th:each="grp : ${block.linkGroups}">
|
||||
<p class="cg-friend-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
|
||||
<ul class="cg-friend-links">
|
||||
<li th:each="l : ${grp.links}">
|
||||
<a th:href="${l.linkUrl}" target="_blank" rel="noopener">
|
||||
<img th:if="${l.logoUrl != null and !#strings.isEmpty(l.logoUrl)}" class="cg-link-logo"
|
||||
th:src="${url.upload(l.logoUrl)}" th:alt="${l.name}" loading="lazy">
|
||||
<span th:text="${l.name}">链接名</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 新区块一:快捷通道(quick_links,手册 §2.8 类型 A)——区块内联入口数组,不读友情链接表。
|
||||
渲染端已丢弃 name 空 / url 非法项(PortalPageRenderer:477-500),模板只判 #lists.isEmpty -->
|
||||
<section class="cg-quick" th:fragment="quick_links(block)" aria-label="快捷通道">
|
||||
<div class="cg-container">
|
||||
<div class="cg-sec-card">
|
||||
<div class="cg-sec-head">
|
||||
<h2 th:text="${block.title}">快捷通道</h2>
|
||||
<a class="cg-more-text" th:if="${block.moreUrl != null}" th:href="${block.moreUrl}">更多 ></a>
|
||||
</div>
|
||||
<ul class="cg-quick-grid" th:if="${!#lists.isEmpty(block['quickLinks'])}">
|
||||
<li th:each="l : ${block['quickLinks']}">
|
||||
<a class="cg-quick-link" th:href="${l.url}" th:text="${l.name}">入口名</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cg-empty" th:if="${#lists.isEmpty(block['quickLinks'])}">
|
||||
<p>暂无可用入口</p>
|
||||
<p class="cg-empty-hint">请在首页布局中配置快捷通道</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 新区块二:公告条(notice_bar,手册 §2.8 类型 B)——站点单条手填通告,非滚动非文章列表。
|
||||
整幅横条(与本包 .cg-ticker 同层级,不是卡片);noticeLevel 渲染端已归一 info|warn;noticeUrl 非法时为空串 → 退化纯文本 -->
|
||||
<div class="cg-notice" th:fragment="notice_bar(block)" role="note"
|
||||
th:if="${!#strings.isEmpty(block['noticeText'])}"
|
||||
th:classappend="${block['noticeLevel'] == 'warn' ? 'cg-notice-warn' : 'cg-notice-info'}">
|
||||
<div class="cg-container cg-notice-inner">
|
||||
<span class="cg-notice-tag" th:text="${block.title}">公告</span>
|
||||
<a class="cg-notice-link" th:if="${!#strings.isEmpty(block['noticeUrl'])}"
|
||||
th:href="${block['noticeUrl']}" th:text="${block['noticeText']}">公告正文</a>
|
||||
<span class="cg-notice-text" th:unless="${!#strings.isEmpty(block['noticeUrl'])}"
|
||||
th:text="${block['noticeText']}">公告正文</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<!-- 事项指南页(事项库 §6.2 契约 · P-A3 换皮):item / attachments[] / relatedItems[] /
|
||||
faqCategoryUrl·noticeCategoryUrl·ruleCategoryUrl(可空)/ applyUrl(可空,空则不渲染"我要办理")。
|
||||
三个富文本字段已在保存期过 jsoup 白名单、渲染期补 URL 基准,此处允许 utext。
|
||||
svcUnavailable=true(P1-4 停机降级):只渲染骨架+空态,不渲染事项内容与"我要办理"。
|
||||
标题/元信息/正文沿用本包既有 .cg-article-title / .cg-article-meta / .cg-article-content,
|
||||
数据绑定与 default、poly-blue 同构,仅换皮。 -->
|
||||
<main class="cg-list-main cg-container" id="main-content">
|
||||
<div class="cg-page-card">
|
||||
<nav class="cg-breadcrumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="cg-crumb-sep" aria-hidden="true">/</span>
|
||||
<a th:href="${url.serviceList()}">服务中心</a>
|
||||
<span class="cg-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="cg-current" th:text="${svcUnavailable} ? '服务中心' : ${item.title}">事项名称</span>
|
||||
</nav>
|
||||
|
||||
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
|
||||
<div class="cg-empty" th:if="${svcUnavailable}">
|
||||
<p>事项服务暂时不可用,请稍后再试</p>
|
||||
<p class="cg-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
|
||||
</div>
|
||||
|
||||
<article th:if="${!svcUnavailable}">
|
||||
<h1 class="cg-article-title" th:text="${item.title}">事项名称</h1>
|
||||
<div class="cg-article-meta">
|
||||
<span th:if="${!#strings.isEmpty(item.deptName)}"
|
||||
th:text="${'办理部门:' + item.deptName}">办理部门</span>
|
||||
<span th:text="${item.handleMode == 1 ? '线上办理' : (item.handleMode == 2 ? '线下办理' : '线上线下均可办理')}">办理方式</span>
|
||||
<time th:if="${item.publishTime != null}"
|
||||
th:text="${'发布时间:' + #temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
<span th:text="${'浏览量:' + item.viewCount}">浏览量</span>
|
||||
</div>
|
||||
|
||||
<!-- 我要办理:三态(手填外链 > svc_hall_base 生成 > 不渲染),按钮外链新窗口打开 -->
|
||||
<div class="cg-svc-apply" th:if="${applyUrl != null}">
|
||||
<a class="cg-svc-apply-btn" th:href="${applyUrl}" target="_blank" rel="noopener">我要办理</a>
|
||||
<span class="cg-svc-apply-hint">进入服务大厅在线办理</span>
|
||||
</div>
|
||||
|
||||
<p class="cg-svc-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">简介</p>
|
||||
|
||||
<table class="cg-svc-info">
|
||||
<caption class="cg-visually-hidden">事项基本信息</caption>
|
||||
<tbody>
|
||||
<tr th:if="${!#strings.isEmpty(item.objectName)}">
|
||||
<th scope="row">服务对象</th>
|
||||
<td th:text="${item.objectName}">服务对象</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.categoryName)}">
|
||||
<th scope="row">事项类别</th>
|
||||
<td th:text="${item.categoryName}">事项类别</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.handlePlace)}">
|
||||
<th scope="row">办理地点</th>
|
||||
<td th:text="${item.handlePlace}">办理地点</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.handleTimeLimit)}">
|
||||
<th scope="row">办理时限</th>
|
||||
<td th:text="${item.handleTimeLimit}">办理时限</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.promiseTimeLimit)}">
|
||||
<th scope="row">承诺时限</th>
|
||||
<td th:text="${item.promiseTimeLimit}">承诺时限</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.contactPhone)}">
|
||||
<th scope="row">咨询电话</th>
|
||||
<td th:text="${item.contactPhone}">咨询电话</td>
|
||||
</tr>
|
||||
<tr th:if="${!#strings.isEmpty(item.supervisePhone)}">
|
||||
<th scope="row">监督投诉</th>
|
||||
<td th:text="${item.supervisePhone}">监督投诉电话</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<section class="cg-svc-section" th:if="${!#strings.isEmpty(item.processHtml)}">
|
||||
<h2>办理流程</h2>
|
||||
<div class="cg-article-content" th:utext="${item.processHtml}">办理流程</div>
|
||||
</section>
|
||||
<section class="cg-svc-section" th:if="${!#strings.isEmpty(item.materialHtml)}">
|
||||
<h2>所需材料</h2>
|
||||
<div class="cg-article-content" th:utext="${item.materialHtml}">所需材料</div>
|
||||
</section>
|
||||
<section class="cg-svc-section" th:if="${!#strings.isEmpty(item.noticeHtml)}">
|
||||
<h2>注意事项</h2>
|
||||
<div class="cg-article-content" th:utext="${item.noticeHtml}">注意事项</div>
|
||||
</section>
|
||||
|
||||
<section class="cg-svc-section cg-svc-attachments" th:if="${!#lists.isEmpty(attachments)}">
|
||||
<h2>相关材料下载</h2>
|
||||
<ul>
|
||||
<li th:each="att : ${attachments}">
|
||||
<a th:href="${url.upload(att.webUrl)}" th:download
|
||||
th:text="${att.fileName}">材料文件</a>
|
||||
<span class="cg-svc-att-size" th:if="${att.fileSize != null}"
|
||||
th:text="${#numbers.formatDecimal(att.fileSize / 1024.0, 1, 1) + ' KB'}">12.3 KB</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<nav class="cg-svc-links" th:if="${faqCategoryUrl != null or noticeCategoryUrl != null or ruleCategoryUrl != null}">
|
||||
<a th:if="${faqCategoryUrl != null}" th:href="${faqCategoryUrl}">常见问题</a>
|
||||
<a th:if="${noticeCategoryUrl != null}" th:href="${noticeCategoryUrl}">公告信息</a>
|
||||
<a th:if="${ruleCategoryUrl != null}" th:href="${ruleCategoryUrl}">文件规定</a>
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<aside class="cg-svc-related" th:if="${!svcUnavailable and !#lists.isEmpty(relatedItems)}">
|
||||
<h2 class="cg-svc-related-title">相关服务</h2>
|
||||
<ul class="cg-svc-related-list">
|
||||
<li th:each="rel : ${relatedItems}">
|
||||
<a th:href="${rel.url}">
|
||||
<span class="cg-svc-related-name" th:text="${rel.title}">相关事项</span>
|
||||
<span class="cg-svc-related-meta">
|
||||
<span th:if="${!#strings.isEmpty(rel.deptName)}" th:text="${rel.deptName}">部门</span>
|
||||
<span class="cg-tag"
|
||||
th:text="${rel.handleMode == 1 ? '线上' : (rel.handleMode == 2 ? '线下' : '线上线下')}">方式</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,24 +23,34 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 首页布局模式(docs/16 §4.4):home_layout 落库后按区块序列渲染。
|
||||
article_list 三种形态走主题自有 fragment(compact/image_wall 与未实现类型落 common 兜底骨架)。
|
||||
注意:th:replace(优先级100)先于 th:case(300)执行——case 必须放父元素、replace 放子元素 -->
|
||||
<th:block th:if="${layoutBlocks != null}" th:each="block : ${layoutBlocks}">
|
||||
<th:block th:switch="${block.type}">
|
||||
<th:block th:case="'article_list'">
|
||||
<th:block th:switch="${block.homeStyle}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbCompact(${block})}" /></th:block>
|
||||
<th:block th:case="'image_wall'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbImageWall(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
<!-- 首页布局模式(docs/16 §4.4 · v2 手册 §A.3 P-A3):home_layout 落库后按区块序列渲染。
|
||||
本包为 A 档:8 形态 + quick_links/notice_bar 全部自持(fragments/home-blocks.html),
|
||||
compact/image_wall/links 三处不再借 themes/common 的 plb 骨架(借骨架 = 换肤只换页头页脚,手册 §2.2 事故根因);
|
||||
只有白名单外的未知类型才落 common 兜底骨架——故 common 的 :: style 样式段必须引入(R-32:本包此前缺这段,兜底区块裸奔)。
|
||||
注意坑④: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}">
|
||||
<th:block th:case="'news_grid'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: news_grid(${block})}" /></th:block>
|
||||
<th:block th:case="'media_focus'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: media_focus(${block})}" /></th:block>
|
||||
<th:block th:case="'compact'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: compact(${block})}" /></th:block>
|
||||
<th:block th:case="'image_wall'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: image_wall(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: card(${block})}" /></th:block>
|
||||
</th:block>
|
||||
</th:block>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: links(${block})}" /></th:block>
|
||||
<th:block th:case="'quick_links'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: quick_links(${block})}" /></th:block>
|
||||
<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>
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: ticker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{${themeBase} + '/fragments/home-blocks' :: gallery(${block})}" /></th:block>
|
||||
<th:block th:case="'links'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbLinks(${block})}" /></th:block>
|
||||
<th:block th:case="*"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: render(${block})}" /></th:block>
|
||||
</div>
|
||||
</th:block>
|
||||
</th:block>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<!-- 服务中心(事项库 §6.1 契约 · P-A3 换皮):serviceList / serviceFilters / activeFilters / q;
|
||||
筛选与分页 URL 全部后端预生成(facets[].url / pageUrls[].url),模板禁止手拼 query。
|
||||
页壳/面包屑/页题/分页/空态沿用本包既有 .cg-list-main / .cg-page-card / .cg-breadcrumb /
|
||||
.cg-page-title / .cg-pagination / .cg-empty;无侧栏,故不进 .cg-page-cols 网格。
|
||||
数据绑定与 default、poly-blue 同构,仅换皮。 -->
|
||||
<main class="cg-list-main cg-container" id="main-content">
|
||||
<div class="cg-page-card">
|
||||
<nav class="cg-breadcrumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<span class="cg-crumb-sep" aria-hidden="true">/</span>
|
||||
<span class="cg-current">服务中心</span>
|
||||
</nav>
|
||||
|
||||
<h1 class="cg-page-title">服务中心</h1>
|
||||
|
||||
<!-- P1-4 停机空态:svc-hall 不可达时降级渲染(200),不出 500 错误页 -->
|
||||
<div class="cg-empty" th:if="${svcUnavailable}">
|
||||
<p>事项服务暂时不可用,请稍后再试</p>
|
||||
<p class="cg-empty-hint">事项数据服务暂不可达,稍后刷新即可恢复</p>
|
||||
</div>
|
||||
|
||||
<div class="cg-svc-filters"
|
||||
th:if="${!svcUnavailable and (!#lists.isEmpty(serviceFilters.objects) or !#lists.isEmpty(serviceFilters.categories) or !#lists.isEmpty(serviceFilters.depts))}">
|
||||
<div class="cg-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.objects)}">
|
||||
<span class="cg-svc-filter-label">服务对象</span>
|
||||
<div class="cg-svc-filter-options">
|
||||
<a class="cg-chip" th:each="opt : ${serviceFilters.objects}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">对象</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.categories)}">
|
||||
<span class="cg-svc-filter-label">事项类别</span>
|
||||
<div class="cg-svc-filter-options">
|
||||
<a class="cg-chip" th:each="opt : ${serviceFilters.categories}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">类别</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cg-svc-filter-group" th:if="${!#lists.isEmpty(serviceFilters.depts)}">
|
||||
<span class="cg-svc-filter-label">服务部门</span>
|
||||
<div class="cg-svc-filter-options">
|
||||
<a class="cg-chip" th:each="opt : ${serviceFilters.depts}"
|
||||
th:classappend="${opt.active} ? 'active'" th:href="${opt.url}">
|
||||
<span th:text="${opt.name}">部门</span><em th:text="${opt.count}">0</em>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cg-svc-toolbar" th:if="${!svcUnavailable}">
|
||||
<form class="cg-svc-search" th:action="${url.serviceList()}" method="get" role="search">
|
||||
<input type="search" name="q" th:value="${q}" placeholder="搜索事项名称 / 简介" maxlength="64">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
<a class="cg-svc-clear" th:if="${activeFilters.hasAny}" th:href="${activeFilters.clearUrl}">清除筛选</a>
|
||||
</div>
|
||||
|
||||
<ul class="cg-svc-list" th:if="${!svcUnavailable}">
|
||||
<li th:each="it : ${serviceList.items}">
|
||||
<a class="cg-svc-item" th:href="${url.serviceItem(it.id)}">
|
||||
<span class="cg-svc-item-cover" th:if="${it.coverImage != null}">
|
||||
<img th:src="${url.upload(it.coverImage)}" th:alt="${it.title}">
|
||||
</span>
|
||||
<span class="cg-svc-item-body">
|
||||
<span class="cg-svc-item-title">
|
||||
<span class="cg-svc-item-name" th:text="${it.title}">事项名称</span>
|
||||
<span class="cg-tag online" th:if="${it.onlineFlag == 1}">可在线办理</span>
|
||||
<span class="cg-tag"
|
||||
th:text="${it.handleMode == 1 ? '线上办理' : (it.handleMode == 2 ? '线下办理' : '线上线下')}">办理方式</span>
|
||||
</span>
|
||||
<span class="cg-svc-item-summary" th:if="${!#strings.isEmpty(it.summary)}"
|
||||
th:text="${it.summary}">简介</span>
|
||||
<span class="cg-svc-item-meta">
|
||||
<span th:if="${!#strings.isEmpty(it.deptName)}" th:text="${it.deptName}">部门</span>
|
||||
<span th:if="${!#strings.isEmpty(it.categoryName)}" th:text="${it.categoryName}">类别</span>
|
||||
<time th:if="${it.publishTime != null}"
|
||||
th:text="${#temporals.format(it.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="cg-empty" th:if="${!svcUnavailable and #lists.isEmpty(serviceList.items)}">
|
||||
<p th:if="${activeFilters.hasAny}">未找到符合条件的服务事项</p>
|
||||
<p th:if="${!activeFilters.hasAny}">服务中心暂未上架事项</p>
|
||||
<p class="cg-empty-hint" th:if="${activeFilters.hasAny}">换个筛选条件或关键词试试</p>
|
||||
<p class="cg-empty-hint" th:if="${!activeFilters.hasAny}">事项发布后将在此展示</p>
|
||||
</div>
|
||||
|
||||
<!-- 分页:URL 由后端预生成(携带当前筛选,pageUrls[].active 标记当前页);停机态 totalPages=0 自然隐藏 -->
|
||||
<nav class="cg-pagination" th:if="${!svcUnavailable and serviceList.totalPages > 1}" aria-label="分页">
|
||||
<a th:each="p : ${serviceList.pageUrls}"
|
||||
th:classappend="${p.active} ? 'active'"
|
||||
th:href="${p.url}"
|
||||
th:aria-label="${'第' + p.page + '页'}"
|
||||
th:aria-current="${p.active} ? 'page'"
|
||||
th:text="${p.page}">1</a>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": "全屏搜索浮层(右上放大镜唤起)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,60 +4,181 @@
|
||||
样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录;
|
||||
颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。
|
||||
契约:block 为渲染端组装的同构 Map——type/title/category.name/homeStyle/items/moreUrl/subtitle/
|
||||
background/source/linkGroups,与回落模式 categoryBlocks 的块字段兼容。
|
||||
background/source/linkGroups/quickLinks/noticeText/noticeUrl/noticeLevel,与回落模式 categoryBlocks 的块字段兼容。
|
||||
-->
|
||||
<th:block th:fragment="style">
|
||||
<style>
|
||||
/* 皮肤穿透层:站点配了 home_layout 时首页由本骨架渲染,颜色必须跟随当前皮肤变量,
|
||||
否则换肤只换了页头页脚、正文区块仍是内置蓝灰(皮肤契约 §6;缺变量时回落内置蓝)。 */
|
||||
:root{
|
||||
--plb-primary: var(--primary, #2d8cf0);
|
||||
--plb-text: var(--text, #1f2d3d);
|
||||
--plb-muted: var(--text-light, #8a94a6);
|
||||
--plb-card-bg: var(--card-bg, #ffffff);
|
||||
--plb-page-bg: var(--bg, #f5f7fb);
|
||||
--plb-band-bg: var(--bg, #f5f7fb);
|
||||
--plb-border: var(--border, rgba(0, 0, 0, .08));
|
||||
--plb-radius: var(--radius, 10px);
|
||||
/* 公告条 warn 档:皮肤契约没有告警色变量,取骨架自持的暖色(不跟随皮肤) */
|
||||
--plb-warn-fg: #d46b08;
|
||||
--plb-warn-bg: #fff7e6;
|
||||
--plb-warn-border: #ffd591;
|
||||
}
|
||||
.plb-section{padding:26px 16px}
|
||||
.plb-container{max-width:1200px;margin:0 auto}
|
||||
.plb-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:0 0 14px}
|
||||
.plb-head h2{margin:0;font-size:20px;line-height:1.3;padding-left:10px;border-left:4px solid var(--primary,#2d8cf0);color:#1f2d3d}
|
||||
.plb-sub{margin:2px 0 0;color:#8a94a6;font-size:12px;letter-spacing:.12em}
|
||||
.plb-more{font-size:13px;color:var(--primary,#2d8cf0);text-decoration:none;white-space:nowrap}
|
||||
.plb-head h2{margin:0;font-size:20px;line-height:1.3;padding-left:10px;border-left:4px solid var(--plb-primary);color:var(--plb-text)}
|
||||
.plb-sub{margin:2px 0 0;color:var(--plb-muted);font-size:12px;letter-spacing:.12em}
|
||||
.plb-more{font-size:13px;color:var(--plb-primary);text-decoration:none;white-space:nowrap}
|
||||
.plb-more:hover{text-decoration:underline}
|
||||
.plb-list{list-style:none;margin:0;padding:0}
|
||||
.plb-list li{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px dashed rgba(0,0,0,.08);font-size:14px}
|
||||
.plb-list li a{flex:1;min-width:0;color:#333;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-list li a:hover{color:var(--primary,#2d8cf0)}
|
||||
.plb-list time{color:#999;font-size:12px;flex:none}
|
||||
.plb-badge{flex:none;font-size:11px;color:#fff;background:var(--primary,#2d8cf0);border-radius:3px;padding:1px 5px}
|
||||
.plb-src{flex:none;font-size:11px;color:var(--primary,#2d8cf0);border:1px solid currentColor;border-radius:3px;padding:1px 6px}
|
||||
.plb-empty{color:#98a2b3;font-size:13px;padding:26px 0;text-align:center}
|
||||
.plb-card{background:#fff;border:1px solid rgba(0,0,0,.06);border-radius:10px;padding:20px 24px;box-shadow:0 4px 18px rgba(15,34,66,.05)}
|
||||
.plb-feat{display:flex;gap:16px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid rgba(0,0,0,.06);text-decoration:none;color:#333}
|
||||
.plb-list li{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px dashed var(--plb-border);font-size:14px}
|
||||
.plb-list li a{flex:1;min-width:0;color:var(--plb-text);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-list li a:hover{color:var(--plb-primary)}
|
||||
.plb-list time{color:var(--plb-muted);font-size:12px;flex:none}
|
||||
.plb-badge{flex:none;font-size:11px;color:#fff;background:var(--plb-primary);border-radius:3px;padding:1px 5px}
|
||||
.plb-src{flex:none;font-size:11px;color:var(--plb-primary);border:1px solid currentColor;border-radius:3px;padding:1px 6px}
|
||||
.plb-empty{color:var(--plb-muted);font-size:13px;padding:26px 0;text-align:center}
|
||||
.plb-card{background:var(--plb-card-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);padding:20px 24px;box-shadow:0 4px 18px rgba(15,34,66,.05)}
|
||||
.plb-feat{display:flex;gap:16px;margin:0 0 12px;padding:0 0 12px;border-bottom:1px solid var(--plb-border);text-decoration:none;color:var(--plb-text)}
|
||||
.plb-feat img{width:200px;height:120px;object-fit:cover;border-radius:6px;flex:none}
|
||||
.plb-feat .t{display:block;font-size:16px;font-weight:600;color:#1f2d3d;margin:0 0 6px}
|
||||
.plb-feat .s{display:block;color:#8a94a6;font-size:13px;line-height:1.6;margin:0 0 6px}
|
||||
.plb-feat .m{color:#999;font-size:12px}
|
||||
.plb-feat .t{display:block;font-size:16px;font-weight:600;color:var(--plb-text);margin:0 0 6px}
|
||||
.plb-feat .s{display:block;color:var(--plb-muted);font-size:13px;line-height:1.6;margin:0 0 6px}
|
||||
.plb-feat .m{color:var(--plb-muted);font-size:12px}
|
||||
.plb-grid2{columns:2;column-gap:40px}
|
||||
.plb-grid2 li{break-inside:avoid}
|
||||
.plb-newshot{position:relative;padding:30px 0;background:#f5f7fb}
|
||||
.plb-newshot{position:relative;padding:30px 0;background:var(--plb-band-bg)}
|
||||
.plb-newshot-bg{position:absolute;inset:0;background-size:cover;background-position:center}
|
||||
.plb-newshot-card{position:relative;background:rgba(255,255,255,.94);border-radius:10px;padding:22px 26px;box-shadow:0 8px 30px rgba(15,34,66,.08)}
|
||||
.plb-newshot-card{position:relative;background:var(--plb-card-bg);border-radius:var(--plb-radius);padding:22px 26px;box-shadow:0 8px 30px rgba(15,34,66,.08)}
|
||||
.plb-imagewall{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
|
||||
.plb-imagewall a{text-decoration:none;color:#333}
|
||||
.plb-imagewall a{text-decoration:none;color:var(--plb-text)}
|
||||
.plb-imagewall img{width:100%;height:120px;object-fit:cover;border-radius:6px;display:block}
|
||||
.plb-imagewall .t{display:block;font-size:13px;margin-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-ticker{background:var(--primary,#2d8cf0);color:#fff;padding:10px 16px;font-size:14px}
|
||||
.plb-ticker{background:var(--plb-primary);color:#fff;padding:10px 16px;font-size:14px}
|
||||
.plb-ticker-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:14px}
|
||||
.plb-ticker-label{flex:none;font-weight:600;letter-spacing:.08em}
|
||||
.plb-ticker-list{display:flex;gap:26px;list-style:none;margin:0;padding:0;overflow:hidden}
|
||||
.plb-ticker-list a{color:#fff;text-decoration:none;opacity:.92}
|
||||
.plb-ticker-list a:hover{opacity:1;text-decoration:underline}
|
||||
/* flex 项自动最小尺寸=min-content,中文可按字断行 → 不锁 nowrap/不收缩就会塌成一字一列 */
|
||||
.plb-ticker-list{display:flex;gap:26px;list-style:none;margin:0;padding:0;flex:1;min-width:0;overflow:hidden;white-space:nowrap}
|
||||
.plb-ticker-list li{flex:none;min-width:0}
|
||||
.plb-ticker-item{display:block;flex:none;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;text-decoration:none;opacity:.92}
|
||||
.plb-ticker-item:hover{opacity:1;text-decoration:underline}
|
||||
.plb-gallery-head{text-align:center;margin:0 0 18px}
|
||||
.plb-gallery-head h2{margin:0;font-size:22px;color:#1f2d3d}
|
||||
.plb-gallery-head h2{margin:0;font-size:22px;color:var(--plb-text)}
|
||||
.plb-gallery-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;list-style:none;margin:0;padding:0}
|
||||
.plb-gallery-grid img{width:100%;height:150px;object-fit:cover;border-radius:8px;display:block}
|
||||
.plb-gallery-grid .t{display:block;font-size:13px;color:#51607a;margin-top:6px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-gallery-grid .t{display:block;font-size:13px;color:var(--plb-muted);margin-top:6px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-gallery-grid a{text-decoration:none}
|
||||
.plb-links{display:flex;flex-wrap:wrap;gap:10px;list-style:none;margin:0;padding:0}
|
||||
.plb-links a{display:inline-block;font-size:13px;color:#51607a;text-decoration:none;border:1px solid rgba(0,0,0,.1);border-radius:16px;padding:4px 14px;background:#fff}
|
||||
.plb-links a:hover{color:var(--primary,#2d8cf0);border-color:var(--primary,#2d8cf0)}
|
||||
.plb-links a{display:inline-block;font-size:13px;color:var(--plb-muted);text-decoration:none;border:1px solid var(--plb-border);border-radius:16px;padding:4px 14px;background:var(--plb-card-bg)}
|
||||
.plb-links a:hover{color:var(--plb-primary);border-color:var(--plb-primary)}
|
||||
.plb-quick-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;list-style:none;margin:0;padding:0}
|
||||
.plb-quick-link{display:flex;align-items:center;justify-content:center;min-height:62px;padding:10px 12px;font-size:14px;line-height:1.4;text-align:center;color:var(--plb-text);text-decoration:none;background:var(--plb-page-bg);border:1px solid var(--plb-border);border-radius:var(--plb-radius);overflow-wrap:anywhere}
|
||||
.plb-quick-link:hover{color:var(--plb-primary);border-color:var(--plb-primary)}
|
||||
.plb-notice-bar{background:var(--plb-band-bg);border-top:1px solid var(--plb-border);border-bottom:1px solid var(--plb-border);padding:10px 16px;font-size:14px}
|
||||
.plb-notice-inner{max-width:1200px;margin:0 auto;display:flex;align-items:center;gap:12px}
|
||||
.plb-notice-tag{flex:none;font-weight:600;color:var(--plb-primary)}
|
||||
/* 中文可按字断行,flex 项不锁 min-width:0 就会被长公告挤破容器 */
|
||||
.plb-notice-link,.plb-notice-text{flex:1;min-width:0;color:var(--plb-text);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.plb-notice-link:hover{text-decoration:underline;color:var(--plb-primary)}
|
||||
.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 无副作用,故由骨架自带。
|
||||
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-grid2{grid-template-columns:1fr}
|
||||
.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)}
|
||||
.plb-gallery-grid{grid-template-columns:repeat(2,1fr)}
|
||||
.plb-quick-grid{grid-template-columns:repeat(3,1fr)}
|
||||
.plb-feat img{width:120px;height:80px}
|
||||
.plb-ticker-item{max-width:150px}
|
||||
}
|
||||
</style>
|
||||
</th:block>
|
||||
@@ -79,6 +200,15 @@
|
||||
<th:block th:case="'ticker'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbTicker(${block})}" /></th:block>
|
||||
<th:block th:case="'gallery'"><th:block th:replace="~{'themes/common/fragments/home-blocks' :: plbGallery(${block})}" /></th:block>
|
||||
<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>
|
||||
@@ -87,12 +217,13 @@
|
||||
<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']}">更多 ></a>
|
||||
</div>
|
||||
|
||||
<!-- 空态 -->
|
||||
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值,写在同一标签上等于没写) -->
|
||||
<div class="plb-empty" th:fragment="empty(block)">
|
||||
<p>该区块暂无内容</p>
|
||||
</div>
|
||||
@@ -121,7 +252,9 @@
|
||||
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -138,7 +271,9 @@
|
||||
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -158,7 +293,9 @@
|
||||
<span class="plb-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -176,7 +313,10 @@
|
||||
<time th:if="${item.publishTime != null}" th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
|
||||
<!-- 守卫必须包在 th:block 外层:th:replace(100)先于 th:if(300)求值,写在同一标签上等于没写 -->
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -193,7 +333,9 @@
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}" th:if="${#lists.isEmpty(block.items)}"></div>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -216,7 +358,8 @@
|
||||
<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}">
|
||||
@@ -243,3 +386,329 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 区块:快捷入口宫格(quick_links,v2 手册 §2.8 类型 A):区块内联数组,不读友情链接表;
|
||||
非法 url 项已由渲染端丢弃,这里只消费 block['quickLinks']=[{name,url}] -->
|
||||
<section class="plb-section plb-quick-links" th:fragment="plbQuickLinks(block)" aria-label="快捷入口">
|
||||
<div class="plb-container">
|
||||
<div class="plb-card">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: head(${block})}"></div>
|
||||
<!-- 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-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 求值) -->
|
||||
<th:block th:if="${#lists.isEmpty(block['quickLinks'])}">
|
||||
<div th:replace="~{'themes/common/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 区块:公告条(notice_bar,v2 手册 §2.8 类型 B):单条手填通告,非滚动非文章列表;
|
||||
level 缺省 info,warn 走告警配色;url 非法时渲染端置空 → 退化为纯文本 -->
|
||||
<div class="plb-notice-bar" th:fragment="plbNoticeBar(block)" role="note"
|
||||
th:if="${!#strings.isEmpty(block['noticeText'])}"
|
||||
th:classappend="${block['noticeLevel'] == 'warn' ? 'plb-notice-warn' : 'plb-notice-info'}">
|
||||
<div class="plb-notice-inner">
|
||||
<span class="plb-notice-tag" th:text="${block['title']}">公告</span>
|
||||
<a class="plb-notice-link" th:if="${!#strings.isEmpty(block['noticeUrl'])}"
|
||||
th:href="${block['noticeUrl']}" th:text="${block['noticeText']}">公告正文</a>
|
||||
<span class="plb-notice-text" th:unless="${!#strings.isEmpty(block['noticeUrl'])}"
|
||||
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": "全屏搜索浮层(右上放大镜唤起)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -602,7 +602,7 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
|
||||
.banner-dots { bottom: 14px; right: 20px; }
|
||||
|
||||
.top-grid { grid-template-columns: 1fr; }
|
||||
.article-list { grid-template-columns: 1fr; }
|
||||
.article-list { grid-template-columns: minmax(0, 1fr); }
|
||||
.article-list.is-list .item-card { flex-direction: column; align-items: stretch; }
|
||||
.article-list.is-list .item-cover { width: 100%; aspect-ratio: 16 / 9; }
|
||||
.top-strip, .category-block { padding: 18px 16px 20px; margin: 16px auto; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# jade-normal(师大青)
|
||||
|
||||
师范院校风皮肤包:以 `default` 为基座派生,设计语言参考信阳师范大学官网(www.xynu.edu.cn,仅学语言不复制实现)——
|
||||
翡翠青主色 + 暖橙点缀,人文师范气质、温润轻盈。
|
||||
配了 `home_layout` 的站点,首页正文区块由本主题 `fragments/home-blocks.html` 渲染——派生包不自带区块形态时,
|
||||
正文会落到 `themes/common` 的通用骨架,换肤就只剩页头页脚(线上事故根因,见《审计验收清单-20260919》)。
|
||||
|
||||
## 风格定位
|
||||
|
||||
- 主色翡翠青 `#008D3F` / 加深 `#04682E` / 提亮 `#35B371`,点缀暖橙 `#F18D00`(日期徽标数字、强调角标、"我要办理"按钮)。
|
||||
- 页头三段式:深青顶条(日期+专业院系)→ 白色品牌行(校徽+校名居左,服务中心入口+圆角搜索居右)→
|
||||
翡翠青通栏主导航(白字,当前/悬停项 primaryLight 底条,≤768px checkbox 纯 CSS 折叠)。
|
||||
- banner 标题用青色底条(非渐变遮罩);列表主打"46px 橙色日期徽标(日大号+年月小号)"语言。
|
||||
- 圆角 8/10px、正文行高 1.8、hover 过渡 0.2s ease;无渐变堆砌、无玻璃拟态、无粉紫。
|
||||
- 页脚:薄荷友链带 + 翡翠深青 `#04682E` 多列(品牌+社媒图标 / 多校区地址+联系版权备案 / 二维码)+ 深色底条。
|
||||
- 全部 12 个颜色参数可在站点 theme_params 微调,另有 1 个 select 布局参数(见下)。
|
||||
|
||||
## 参数取值建议(theme_params)
|
||||
|
||||
| key | 默认 | 说明 |
|
||||
| -- | -- | -- |
|
||||
| `primary` | `#008D3F` | 翡翠青主色(导航/标题/链接) |
|
||||
| `primaryDark` | `#04682E` | 加深(顶条/页脚/hover) |
|
||||
| `primaryLight` | `#35B371` | 提亮(当前栏目底条/描边) |
|
||||
| `accent` | `#F18D00` | 暖橙点缀(日期徽标/角标) |
|
||||
| `navBg` | `#008D3F` | 主导航条底色(可与主色分离调) |
|
||||
| `footerBg` | `#04682E` | 页脚主底色 |
|
||||
| `text` / `textLight` | `#2E3A33` / `#6E7F75` | 正文/次要文字 |
|
||||
| `bg` / `cardBg` / `border` | `#F4FAF6` / `#FFFFFF` / `#DFEEE5` | 页面底/卡片/边框 |
|
||||
| `mintBg` | `#E7F6EE` | 薄荷底(背景带/友链区/hover 面) |
|
||||
| `listLayout` | `badge` | 列表页形态:`badge` 橙色日期徽标行 / `plain` 通栏行式(日期右置) |
|
||||
|
||||
## 首页区块形态(布局模式与回落模式同构)
|
||||
|
||||
| homeStyle | 呈现 | 类名 |
|
||||
| -- | -- | -- |
|
||||
| `news_grid` | 校园要闻:左图文头条 + 右橙色日期徽标行 + 四图横排;区块背景可取站点 `news_bg_image` | `jn-split` / `jn-date-badge` / `jn-shots` |
|
||||
| `compact` | 通知公告:46px「日大号+年月小号」橙色徽标行 | `jn-row-badge` |
|
||||
| `media_focus` | 学术交流/媒体聚焦:来源徽标 + 双列密行 | `jn-src` / `jn-block-list--two` |
|
||||
| `image_wall` | 教学科研:四列图片墙,青底标题条,hover 抬图 | `jn-wall` |
|
||||
| (空/未知) | 整宽区块卡:头条 + 行式列表 | `jn-block--flow` |
|
||||
| `ticker` | 翡翠青要闻速递横条(单行不换行、超长省略,中文不塌列) | `jn-ticker*` |
|
||||
| `gallery` | 校园映像四图墙 + 「融媒师大」图标行(微信/微博/抖音/B站走 `social_*` 配置,校报/广播台常驻) | `jn-gallery` / `jn-fusion` |
|
||||
| `links` | 快捷通道图标宫格(6 列内联 SVG 图标卡,青色描边 hover 青底白字) | `jn-quick` |
|
||||
|
||||
## 契约要点
|
||||
|
||||
- 结构类前缀 `jn-`,CSS 变量前缀 `--jn-`;`:root` 把 `--jn-*` 别名到皮肤契约变量(`--primary`/`--text`/`--card-bg`/`--border`…),
|
||||
使 `themes/common` 兜底骨架也能跟随本皮肤着色。
|
||||
- 空态文案「栏目暂无内容」只在 `block.items` 为空时出现;守卫必须写在 `th:block` 外层——
|
||||
`th:replace`(优先级 100)先于 `th:if`(300)求值,写在同一标签上等于没写。
|
||||
- 页脚消费 docs/12 增强键:`footer_addresses`(经 `site.footerAddressLines` 逐行渲染)、`qr_wechat`/`qr_weibo`、
|
||||
`social_wechat`/`social_weibo`/`social_douyin`/`social_bilibili`——均有值才渲染,空站点优雅降级。
|
||||
- 回归防线:`ThemeContractIT`(7 条路由);带 home_layout 的站点首页不落通用骨架(本包 home.html 分发到自家 fragment)。
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,88 @@
|
||||
/* jade-normal(师大青)皮肤包脚本:顶条日期 + 首页轮播(自动播放/圆点/箭头/悬停暂停/键盘),无任何外部依赖。
|
||||
选择器与 main.css / home.html 的 jn-* 类名一一对应;导航折叠由 CSS(checkbox + label)承担,无需 JS。 */
|
||||
(function () {
|
||||
/* 顶条日期(本地时间,YYYY年M月D日 星期X) */
|
||||
var dateEl = document.getElementById('siteDate');
|
||||
if (dateEl) {
|
||||
var now = new Date();
|
||||
var week = ['日', '一', '二', '三', '四', '五', '六'][now.getDay()];
|
||||
dateEl.textContent = now.getFullYear() + '年' + (now.getMonth() + 1) + '月'
|
||||
+ now.getDate() + '日 星期' + week;
|
||||
}
|
||||
|
||||
/* 轮播(.jn-banner,见 home.html) */
|
||||
var root = document.querySelector('.jn-banner');
|
||||
var slides = document.querySelectorAll('.jn-banner-slide');
|
||||
var dots = document.querySelectorAll('.jn-banner-dots button');
|
||||
var prevBtn = document.querySelector('.jn-banner-arrow.prev');
|
||||
var nextBtn = document.querySelector('.jn-banner-arrow.next');
|
||||
if (!slides.length || !root) {
|
||||
return;
|
||||
}
|
||||
var current = 0;
|
||||
var timer = null;
|
||||
var INTERVAL = 5000;
|
||||
|
||||
function show(index) {
|
||||
slides[current].classList.remove('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.remove('active');
|
||||
}
|
||||
current = (index + slides.length) % slides.length;
|
||||
slides[current].classList.add('active');
|
||||
if (dots[current]) {
|
||||
dots[current].classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop();
|
||||
if (slides.length > 1) {
|
||||
timer = window.setInterval(function () {
|
||||
show(current + 1);
|
||||
}, INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dots.forEach(function (dot, index) {
|
||||
dot.addEventListener('click', function () {
|
||||
show(index);
|
||||
start();
|
||||
});
|
||||
});
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', function () {
|
||||
show(current - 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', function () {
|
||||
show(current + 1);
|
||||
start();
|
||||
});
|
||||
}
|
||||
root.addEventListener('mouseenter', stop);
|
||||
root.addEventListener('mouseleave', start);
|
||||
root.addEventListener('focusin', stop);
|
||||
root.addEventListener('focusout', start);
|
||||
root.setAttribute('tabindex', '-1');
|
||||
root.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
show(current - 1);
|
||||
start();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
show(current + 1);
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
start();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<head th:replace="~{${themeBase} + '/fragments/head' :: head}"></head>
|
||||
<body>
|
||||
<header th:replace="~{${themeBase} + '/fragments/header' :: header}"></header>
|
||||
|
||||
<main class="jn-detail-main jn-container" id="main-content">
|
||||
<div class="jn-page-cols">
|
||||
<!-- 左侧栏目导航(docs/11):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||
<aside class="jn-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||
<div class="jn-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||
<ul class="jn-side-nav-list">
|
||||
<li class="jn-side-nav-item" th:each="item : ${sideNav.items}"
|
||||
th:classappend="${item.id == sideNav.activeId} ? 'active'">
|
||||
<a th:if="${item.type == 3}" th:href="${item.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${item.name}">子栏目</a>
|
||||
<a th:unless="${item.type == 3}" th:href="${url.category(item.id)}" th:text="${item.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<div class="jn-page-card">
|
||||
<nav class="jn-breadcrumb" aria-label="面包屑">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
<th:block th:each="node : ${categoryPath}">
|
||||
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||
<a class="crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||
</th:block>
|
||||
<span class="crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||
<span class="current">正文</span>
|
||||
</nav>
|
||||
|
||||
<article class="jn-article">
|
||||
<h1 class="jn-article-title" th:text="${article.title}">文章标题</h1>
|
||||
<div class="jn-article-meta">
|
||||
<time th:if="${article.publishTime != null}"
|
||||
th:text="${'发布时间:' + #temporals.format(article.publishTime, 'yyyy-MM-dd HH:mm')}">2026-01-01 10:00</time>
|
||||
<span th:if="${!#strings.isEmpty(article.authorName)}"
|
||||
th:text="'作者:' + ${article.authorName}">作者</span>
|
||||
<span th:if="${!#strings.isEmpty(article.source)}"
|
||||
th:text="'来源:' + ${article.source}">来源</span>
|
||||
<span th:text="'浏览量:' + ${article.viewCount}">浏览量</span>
|
||||
</div>
|
||||
<!-- 正文:入库前已经 jsoup 白名单清洗(方案 §8.5),此处允许 utext;
|
||||
其余字段一律 th:text 转义输出 -->
|
||||
<div class="jn-article-content" th:utext="${article.content}">正文</div>
|
||||
</article>
|
||||
|
||||
<nav class="jn-sibling" aria-label="上下篇导航">
|
||||
<a th:if="${prevArticle != null}" class="prev"
|
||||
th:href="${url.article(prevArticle.id)}">
|
||||
<span class="jn-sib-label">上一篇</span>
|
||||
<span class="jn-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
|
||||
</a>
|
||||
<span class="jn-sib-none prev" th:if="${prevArticle == null}">
|
||||
<span class="jn-sib-label">上一篇</span>
|
||||
<span class="jn-sib-title">已经是最早一篇</span>
|
||||
</span>
|
||||
<a th:if="${nextArticle != null}" class="next"
|
||||
th:href="${url.article(nextArticle.id)}">
|
||||
<span class="jn-sib-label">下一篇</span>
|
||||
<span class="jn-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
|
||||
</a>
|
||||
<span class="jn-sib-none next" th:if="${nextArticle == null}">
|
||||
<span class="jn-sib-label">下一篇</span>
|
||||
<span class="jn-sib-title">已是最新一篇</span>
|
||||
</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
||||
<script th:src="${url.asset('js/main.js')}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- jade-normal 公共页脚(docs/12 个性化页脚):薄荷友链带(分组三态渲染) + 翡翠深青多列主区
|
||||
(品牌+社媒图标行 / 多校区地址+联系版权备案 / 二维码) + 深色底条(校名)。
|
||||
基础数据契约与 default 一致(linkGroups/contact/copyright/beian/logo/schName);
|
||||
新增消费 config 键:footer_addresses(经 site.footerAddressLines 派生)/qr_wechat/qr_weibo/
|
||||
social_wechat/social_weibo/social_douyin/social_bilibili(均有值才渲染,空站点优雅降级)。 -->
|
||||
<footer class="jn-footer-wrap" th:fragment="footer">
|
||||
<!-- 友情链接(无分组的站整段不渲染;分组无链接时防御性跳过——数据上分组由链接行物化,不可达) -->
|
||||
<div class="jn-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||
<div class="jn-footer-links-inner">
|
||||
<div class="jn-links-group" th:each="group : ${site.linkGroups}"
|
||||
th:if="${!#lists.isEmpty(group.links)}">
|
||||
<span class="jn-group-name" th:text="${group.groupName}">分组</span>
|
||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
|
||||
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||
<span th:text="${link.name}">链接</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主区:品牌+社媒 | 地址/联系/版权/备案 | 二维码(config_json: contact/copyright/beian) -->
|
||||
<div class="jn-footer">
|
||||
<div class="jn-footer-inner">
|
||||
<!-- 品牌列:校徽 + 站名 + 社媒图标(social_* 有值才渲染) -->
|
||||
<div class="jn-footer-brand">
|
||||
<div class="jn-footer-logo-row">
|
||||
<span class="jn-footer-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="jn-footer-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<span class="jn-footer-badge" th:if="${site.config['logo'] == null}" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false">
|
||||
<path d="M12 2l2.4 4.9 5.4.8-3.9 3.8.9 5.4L12 14.4 7.2 16.9l.9-5.4L4.2 7.7l5.4-.8L12 2z"
|
||||
fill="currentColor"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-footer-site" th:text="${site.name}">站点名称</span>
|
||||
</div>
|
||||
<div class="jn-social" th:if="${site.config['social_wechat'] != null
|
||||
or site.config['social_weibo'] != null
|
||||
or site.config['social_douyin'] != null
|
||||
or site.config['social_bilibili'] != null}">
|
||||
<a th:if="${site.config['social_wechat'] != null}" class="jn-social-icon"
|
||||
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_weibo'] != null}" class="jn-social-icon"
|
||||
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_douyin'] != null}" class="jn-social-icon"
|
||||
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a th:if="${site.config['social_bilibili'] != null}" class="jn-social-icon"
|
||||
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M7.2 4.3 9 6.4h6L16.8 4.3c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息列:多校区地址(footer_addresses 已按行拆好)+ 联系/版权/备案 -->
|
||||
<div class="jn-footer-info">
|
||||
<ul class="jn-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
|
||||
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
|
||||
</ul>
|
||||
<p class="jn-footer-line" th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
|
||||
<p class="jn-footer-line" th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
|
||||
<p class="jn-footer-line" th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
|
||||
</div>
|
||||
|
||||
<!-- 二维码列(qr_wechat/qr_weibo 有值才渲染) -->
|
||||
<div class="jn-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
|
||||
<figure class="jn-qr" th:if="${site.config['qr_wechat'] != null}">
|
||||
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
|
||||
<figcaption>微信扫码</figcaption>
|
||||
</figure>
|
||||
<figure class="jn-qr" th:if="${site.config['qr_weibo'] != null}">
|
||||
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
|
||||
<figcaption>微博扫码</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底条:全站无任何配置信息时仍保留结构完整(空块由 CSS 收敛) -->
|
||||
<div class="jn-footer-bottom">
|
||||
<div class="jn-footer-bottom-inner">
|
||||
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
|
||||
th:text="${site.schName}">学校名称</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||
<!-- jade-normal 公共 head 片段:SEO(title/description/canonical/og)+ 主题资产(css/main.css + js/main.js
|
||||
+ favicon 兜底)+ 微调参数 CSS 变量覆盖(themeVarsCss)+ 站点自定义样式(customCss)。
|
||||
数据绑定与 default 契约一致,仅换皮。 -->
|
||||
<head th:fragment="head">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title th:text="${pageTitle}">页面标题</title>
|
||||
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
||||
th:content="${seoDescription}">
|
||||
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
|
||||
<!-- og 标签基本三件(C-7,方案 §8.6 canonical/og 同基准) -->
|
||||
<meta property="og:title" th:if="${pageTitle != null}" th:content="${pageTitle}">
|
||||
<meta property="og:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
|
||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||
<link rel="icon" type="image/png"
|
||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||
</head>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<!-- jade-normal 公共头部:深青顶条(日期+专业院系) + 白色品牌行(校徽/站名居左,搜索与工具链接居右)
|
||||
+ 翡翠青通栏主导航(白字,当前项 primaryLight 底条,≤768px checkbox 纯 CSS 折叠)。
|
||||
数据绑定与 default 契约一致(site.colleges 空则不渲染院系下拉),仅换皮。 -->
|
||||
<header class="jn-header" th:fragment="header">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
|
||||
<!-- 顶条:左侧今日日期(main.js 填充);右侧院系子站切换(《院系子站方案》):顶层站取其子站、
|
||||
院系子站取兄弟站(含自身);无子站整块不渲染;文案经 config_json.colleges_label 覆盖 -->
|
||||
<div class="jn-topbar">
|
||||
<div class="jn-topbar-inner">
|
||||
<span class="jn-topbar-date" id="siteDate" th:aria-label="'今日日期'">加载日期…</span>
|
||||
<div class="jn-topbar-side" th:if="${!site.colleges.isEmpty()}"
|
||||
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
|
||||
<div class="jn-colleges">
|
||||
<span class="jn-colleges-toggle">
|
||||
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
|
||||
<i class="jn-colleges-arrow" aria-hidden="true">▾</i>
|
||||
</span>
|
||||
<ul class="jn-colleges-menu">
|
||||
<li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
|
||||
<a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
|
||||
th:title="${col.name}" th:text="${col.name}">学院名称</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 品牌行:校徽+校名居左;右侧服务中心入口 + 站内搜索 -->
|
||||
<div class="jn-brandbar">
|
||||
<div class="jn-brandbar-inner">
|
||||
<div class="jn-brand">
|
||||
<span class="jn-brand-badge" th:if="${site.config['logo'] != null}">
|
||||
<img class="jn-logo" th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||
</span>
|
||||
<a class="jn-site-name" th:href="${url.home()}" th:text="${site.name}">站点名称</a>
|
||||
</div>
|
||||
<div class="jn-tools">
|
||||
<a class="jn-tool-link" th:href="${url.serviceList()}">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8.5"></circle>
|
||||
<path d="M12 8v4.5l3 2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<span>服务中心</span>
|
||||
</a>
|
||||
<form class="jn-search" th:action="${url.search('', null)}" method="get" role="search">
|
||||
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
|
||||
<button type="submit">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" 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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 栏目导航:翡翠青通栏(白字,当前项 primaryLight 底条);外链栏目直接外跳,其余进栏目页(方案 §8.1);
|
||||
≤768px 由 checkbox + label 纯 CSS 折叠(main.css §5/§21),无 JS 依赖 -->
|
||||
<nav class="jn-nav" aria-label="栏目导航">
|
||||
<input type="checkbox" id="jn-nav-fold" class="jn-nav-fold">
|
||||
<label class="jn-nav-btn" for="jn-nav-fold">栏目菜单</label>
|
||||
<ul>
|
||||
<li th:classappend="${currentCategoryId == null and navServiceActive == null} ? 'active'">
|
||||
<a th:href="${url.home()}">首页</a>
|
||||
</li>
|
||||
<li th:each="cat : ${navCategories}"
|
||||
th:classappend="${((currentCategoryId == cat.id or navTopId == cat.id) ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
|
||||
<a th:if="${cat.type == 3}" th:href="${cat.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${cat.name}">栏目</a>
|
||||
<a th:unless="${cat.type == 3}" th:href="${url.category(cat.id)}"><span th:text="${cat.name}">栏目</span><i th:if="${!cat.children.isEmpty()}" class="caret" aria-hidden="true">▾</i></a>
|
||||
<!-- 二级下拉:悬停展开(.jn-sub 样式见 assets/css/main.css);当前项高亮(docs/11) -->
|
||||
<ul class="jn-sub" th:if="${!cat.children.isEmpty()}">
|
||||
<li th:each="sub : ${cat.children}"
|
||||
th:classappend="${sub.id == currentCategoryId} ? 'active'">
|
||||
<a th:if="${sub.type == 3}" th:href="${sub.linkUrl}" target="_blank" rel="noopener"
|
||||
th:text="${sub.name}">子栏目</a>
|
||||
<a th:unless="${sub.type == 3}" th:href="${url.category(sub.id)}" th:text="${sub.name}">子栏目</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</html>
|
||||
@@ -0,0 +1,316 @@
|
||||
<!--
|
||||
jade-normal(师大青)首页区块 fragment:站点配了 home_layout 时,区块正文必须由本文件渲染,
|
||||
不能落到 themes/common 的通用骨架——否则换肤只换了页头页脚,首页正文仍是骨架内置蓝灰卡(线上事故根因)。
|
||||
结构复用主题 main.css 第 7-16 节语汇(jn-block / jn-block-head / jn-date-badge / jn-block-item /
|
||||
jn-wall / jn-ticker / jn-fusion / jn-quick),仅布局模式特有的排布(双栏要闻、四图横排、速递条、
|
||||
快捷图标宫格、融媒图标行)另立第 10-15 节。
|
||||
契约:block 为渲染端装配的同构 Map(category.name/title/homeStyle/items/moreUrl/background/
|
||||
subtitle/linkGroups),与回落模式 categoryBlocks 兼容;区块标题一律取 category.name(两种模式都有值)。
|
||||
注意:th:replace(优先级 100)先于 th:if(300)执行——空态守卫与形态分发都必须把 replace 放子元素。
|
||||
-->
|
||||
<!-- 形态一:校园要闻(homeStyle=news_grid)——左图文头条 + 右橙色日期徽标行 + 四图横排(信阳师大要闻区语言) -->
|
||||
<section class="jn-section jn-container jn-news-sec" th:fragment="news_grid(block)" aria-label="栏目区块"
|
||||
th:with="newsBg=${block['background'] != null ? (block['background'] ? site.config['news_bg_image'] : null) : site.config['news_bg_image']}">
|
||||
<div class="jn-sec-bg" th:if="${!#strings.isEmpty(newsBg)}"
|
||||
th:style="|background-image:url(${url.upload(newsBg)})|"></div>
|
||||
<div class="jn-block jn-block--flow"
|
||||
th:with="lead=${!#lists.isEmpty(block.items) ? block.items[0] : null},
|
||||
rest=${#lists.size(block.items) > 1 ? block.items.subList(1, block.items.size()) : null}">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.category.name}">校园要闻</h2>
|
||||
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
|
||||
</div>
|
||||
<div class="jn-split" th:if="${lead != null}">
|
||||
<div class="jn-block-featured">
|
||||
<a th:href="${url.article(lead.id)}" class="jn-feat-a--stack">
|
||||
<span class="jn-feat-cover jn-feat-cover--row" th:if="${lead.coverImage != null}">
|
||||
<img th:src="${url.upload(lead.coverImage)}" th:alt="${lead.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="jn-feat-body">
|
||||
<span class="jn-feat-title" th:text="${lead.title}">头条标题</span>
|
||||
<span class="jn-feat-summary" th:if="${!#strings.isEmpty(lead.summary)}"
|
||||
th:text="${lead.summary}">摘要</span>
|
||||
<span class="jn-feat-meta">
|
||||
<span class="jn-top-badge" th:if="${lead.topmost == 1}">置顶</span>
|
||||
<time th:if="${lead.publishTime != null}"
|
||||
th:text="${#temporals.format(lead.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="jn-block-list jn-split-side" th:if="${rest != null}">
|
||||
<li class="jn-block-item jn-row-badge" th:each="item : ${rest}">
|
||||
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- 四图横排:取余下有封面的条目,四列网格,不足即少几格、超出自动换行 -->
|
||||
<ul class="jn-shots" th:if="${rest != null}">
|
||||
<li class="jn-shot" th:each="item : ${rest}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="jn-shot-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态二:媒体聚焦(homeStyle=media_focus)——来源徽标 + 双列密行 -->
|
||||
<section class="jn-section jn-container" th:fragment="media_focus(block)" aria-label="栏目区块">
|
||||
<div class="jn-block jn-block--flow">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.category.name}">媒体聚焦</h2>
|
||||
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
|
||||
</div>
|
||||
<ul class="jn-block-list jn-block-list--two" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="jn-block-item" th:each="item : ${block.items}">
|
||||
<span class="jn-src" th:if="${!#strings.isEmpty(item.source)}" th:text="${item.source}">媒体来源</span>
|
||||
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<time class="jn-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态三:通知公告(homeStyle=compact)——橙色日期徽标行(46px:日大号 + 年月小号) -->
|
||||
<section class="jn-section jn-container" th:fragment="compact(block)" aria-label="栏目区块">
|
||||
<div class="jn-block jn-block--flow">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.category.name}">通知公告</h2>
|
||||
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
|
||||
</div>
|
||||
<ul class="jn-block-list" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="jn-block-item jn-row-badge" th:each="item : ${block.items}">
|
||||
<span class="jn-date-badge" th:if="${item.publishTime != null}" th:with="d=${item.publishTime}">
|
||||
<b th:text="${#temporals.format(d, 'dd')}">01</b>
|
||||
<i th:text="${#temporals.format(d, 'yyyy-MM')}">2026-01</i>
|
||||
</span>
|
||||
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 形态四:图片墙(homeStyle=image_wall)——四列图片墙,仅取有封面条目,青底标题条 -->
|
||||
<section class="jn-section jn-container" th:fragment="image_wall(block)" aria-label="栏目区块">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.category.name}">图片新闻</h2>
|
||||
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
|
||||
</div>
|
||||
<ul class="jn-wall" th:if="${!#lists.isEmpty(block.items)}">
|
||||
<li class="jn-wall-item" th:each="item : ${block.items}" th:if="${item.coverImage != null}">
|
||||
<a th:href="${url.article(item.id)}" th:title="${item.title}">
|
||||
<img th:src="${url.upload(item.coverImage)}" th:alt="${item.title}" loading="lazy">
|
||||
<span class="jn-wall-cap" th:text="${item.title}">图片标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</section>
|
||||
|
||||
<!-- 形态五:默认卡(homeStyle 未设/未知)——整宽头条 + 行式列表 -->
|
||||
<section class="jn-section jn-container" th:fragment="card(block)" aria-label="栏目区块">
|
||||
<div class="jn-block jn-block--flow">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
||||
<a class="jn-more" th:href="${block.moreUrl}">更多</a>
|
||||
</div>
|
||||
<div class="jn-block-featured" th:if="${!#lists.isEmpty(block.items)}"
|
||||
th:with="feat=${block.items[0]}">
|
||||
<a th:href="${url.article(feat.id)}">
|
||||
<span class="jn-feat-cover" th:if="${feat.coverImage != null}">
|
||||
<img th:src="${url.upload(feat.coverImage)}" th:alt="${feat.title}" loading="lazy">
|
||||
</span>
|
||||
<span class="jn-feat-body">
|
||||
<span class="jn-feat-title" th:text="${feat.title}">头条标题</span>
|
||||
<span class="jn-feat-summary" th:if="${!#strings.isEmpty(feat.summary)}" th:text="${feat.summary}">摘要</span>
|
||||
<span class="jn-feat-meta">
|
||||
<span class="jn-top-badge" th:if="${feat.topmost == 1}">置顶</span>
|
||||
<time th:if="${feat.publishTime != null}"
|
||||
th:text="${#temporals.format(feat.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<ul class="jn-block-list" th:if="${#lists.size(block.items) > 1}">
|
||||
<li class="jn-block-item" th:each="item, stat : ${block.items}" th:if="${stat.index > 0}">
|
||||
<a class="jn-block-item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
||||
<span class="jn-top-badge" th:if="${item.topmost == 1}">置顶</span>
|
||||
<time class="jn-item-date" th:if="${item.publishTime != null}"
|
||||
th:text="${#temporals.format(item.publishTime, 'MM-dd')}">01-01</time>
|
||||
</li>
|
||||
</ul>
|
||||
<th:block th:if="${#lists.isEmpty(block.items)}">
|
||||
<div th:replace="~{'themes/jade-normal/fragments/home-blocks' :: empty(${block})}"></div>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:要闻速递横条(block.items 由渲染端按置顶+最新装配;单行不换行,中文不塌列) -->
|
||||
<section class="jn-ticker" th:fragment="ticker(block)" aria-label="要闻速递"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="jn-container jn-ticker-inner">
|
||||
<span class="jn-ticker-label" th:text="${block.title}">要闻速递</span>
|
||||
<ul class="jn-ticker-list">
|
||||
<li th:each="item : ${block.items}">
|
||||
<a class="jn-ticker-item" th:href="${url.article(item.id)}" th:text="${item.title}">要闻标题</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:校园映像 + 融媒师大(block.items = 轮播位 gallery;融媒图标行消费站点 social_* 配置) -->
|
||||
<section class="jn-section jn-container jn-gallery" th:fragment="gallery(block)" aria-label="线上展厅"
|
||||
th:if="${!#lists.isEmpty(block.items)}">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.title}">校园映像</h2>
|
||||
</div>
|
||||
<p class="jn-gallery-sub" th:if="${!#strings.isEmpty(block['subtitle'])}" th:text="${block['subtitle']}">
|
||||
Campus Gallery
|
||||
</p>
|
||||
<ul class="jn-wall">
|
||||
<li class="jn-wall-item" th:each="g : ${block.items}">
|
||||
<a th:href="${g.linkUrl}" th:title="${g.title}">
|
||||
<img th:src="${g.imageUrl}" th:alt="${g.title}" loading="lazy">
|
||||
<span class="jn-wall-cap" th:if="${!#strings.isEmpty(g.title)}" th:text="${g.title}">作品标题</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 融媒师大:微信/微博/抖音/B站走站点 social_* 配置(有值才渲染),校报/广播台为常驻静态卡 -->
|
||||
<div class="jn-fusion">
|
||||
<p class="jn-fusion-label">融媒师大</p>
|
||||
<div class="jn-fusion-row">
|
||||
<a th:if="${site.config['social_wechat'] != null}" class="jn-fusion-item"
|
||||
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M9.3 4C5.8 4 3 6.4 3 9.4c0 1.7.9 3.2 2.4 4.2l-.6 2 2.2-1.1c.6.2 1.3.3 2 .3h.4a5 5 0 0 1-.2-1.4c0-2.9 2.7-5.2 6-5.2h.3C14.9 5.9 12.3 4 9.3 4zM7 7.5a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zm4.7 0a.9.9 0 1 1 0 1.8.9.9 0 0 1 0-1.8zM15 9.6c-3 0-5.4 2-5.4 4.5S12 18.5 15 18.5c.6 0 1.2-.1 1.7-.3l1.9 1-.5-1.8c1.3-.9 2.2-2.2 2.2-3.7 0-2.5-2.4-4.1-5.3-4.1zm-1.9 2.5a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6zm3.8 0a.8.8 0 1 1 0 1.6.8.8 0 0 1 0-1.6z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">微信</span>
|
||||
</a>
|
||||
<a th:if="${site.config['social_weibo'] != null}" class="jn-fusion-item"
|
||||
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M10.1 19.6c-3.5.3-6.6-1.3-6.9-3.6-.3-2.2 2.3-4.4 5.8-4.8 3.5-.3 6.6 1.3 6.9 3.6.3 2.3-2.3 4.5-5.8 4.8zm3.2-4.9c-.4-1.3-2-2-3.8-1.8-1.9.3-3.2 1.6-2.9 3 .3 1.3 2 2.1 3.9 1.8 1.8-.3 3.1-1.7 2.8-3zm-3.1 1.9a.7.7 0 0 1-.9.3.6.6 0 0 1-.2-.9c.2-.3.6-.4.9-.2.3.1.4.5.2.8zm.8-1a.3.3 0 0 1-.4.1c-.2 0-.3-.2-.2-.4.1-.1.3-.2.4-.1.2 0 .3.2.2.4zm3.6-7.3a3.9 3.9 0 0 0-3.6-1.1.5.5 0 0 0-.4.6c.1.3.4.5.6.4a2.9 2.9 0 0 1 3.3 3.7c-.1.3.1.5.4.6.2 0 .5-.1.6-.4.4-1.4.1-2.8-.9-3.8zm2.6-1a6.8 6.8 0 0 0-6.3-2 .5.5 0 0 0-.4.6c0 .3.3.5.6.4a5.8 5.8 0 0 1 6.7 7.4c-.1.3.1.5.3.6.3.1.6 0 .7-.3.8-2.3.3-4.9-1.6-6.7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">微博</span>
|
||||
</a>
|
||||
<a th:if="${site.config['social_douyin'] != null}" class="jn-fusion-item"
|
||||
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M16.6 3c.4 1.8 1.6 3.2 3.4 3.5v2.8c-1.3 0-2.5-.4-3.5-1.1v5.6a5.6 5.6 0 1 1-5.6-5.6c.3 0 .6 0 .9.1v2.9a2.7 2.7 0 1 0 1.9 2.6V3h2.9z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">抖音</span>
|
||||
</a>
|
||||
<a th:if="${site.config['social_bilibili'] != null}" class="jn-fusion-item"
|
||||
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M7.2 4.3 9 6.4h6L16.8 4.3c.3-.4.9-.4 1.2-.1.3.3.4.8.1 1.2l-1 1h1.4A3.5 3.5 0 0 1 22 9.9v6.6a3.5 3.5 0 0 1-3.5 3.5h-13A3.5 3.5 0 0 1 2 16.5V9.9a3.5 3.5 0 0 1 3.5-3.5h1.4l-1-1A.9.9 0 0 1 6 4.2c.3-.3.9-.3 1.2.1zM5.5 8.2A1.7 1.7 0 0 0 3.8 9.9v6.6c0 .9.8 1.7 1.7 1.7h13c.9 0 1.7-.8 1.7-1.7V9.9c0-.9-.8-1.7-1.7-1.7h-13zm3 3.2c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9zm7 0c.5 0 .9.4.9.9v.9a.9.9 0 1 1-1.8 0v-.9c0-.5.4-.9.9-.9z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">哔哩哔哩</span>
|
||||
</a>
|
||||
<span class="jn-fusion-item" aria-label="校报" title="校报">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M6 3h11a1 1 0 0 1 1 1v14a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4a1 1 0 0 1 1-1zm1 2v2h9V5H7zm0 4v2h4V9H7zm6 0v2h3V9h-3zM7 13v2h4v-2H7zm6 0v2h3v-2h-3zM7 17v2h4v-2H7zm6 0v2h3a1 1 0 0 0 1-1v-1h-4z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">校报</span>
|
||||
</span>
|
||||
<span class="jn-fusion-item" aria-label="校园广播台" title="校园广播台">
|
||||
<span class="jn-fusion-ico">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" focusable="false" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 4a1 1 0 0 1 .9.6l3.6 8a1 1 0 0 1-1.3 1.3l-2.3-1v3.6a3.5 3.5 0 1 1-2 0v-3.6l-2.3 1a1 1 0 0 1-1.3-1.3l3.6-8A1 1 0 0 1 12 4zm-1 12.6a1.5 1.5 0 1 0 2 0 1.5 1.5 0 0 0-2 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="jn-fusion-name">广播台</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 布局模式:快捷通道图标宫格(block.linkGroups 由渲染端按 group 过滤;对应信阳师大「信息门户/协同办公/教务管理/网络教学/财务平台/校园卡服务」快捷入口条) -->
|
||||
<section class="jn-section jn-container" th:fragment="links(block)" aria-label="快捷通道">
|
||||
<div class="jn-block jn-block--flow">
|
||||
<div class="jn-block-head">
|
||||
<h2 th:text="${block.title}">快捷通道</h2>
|
||||
</div>
|
||||
<th:block th:each="grp : ${block.linkGroups}">
|
||||
<p class="jn-quick-group" th:if="${!#strings.isEmpty(grp.groupName)}" th:text="${grp.groupName}">分组</p>
|
||||
<ul class="jn-quick-grid">
|
||||
<li class="jn-quick-item" th:each="l, stat : ${grp.links}">
|
||||
<a th:href="${l.linkUrl}" th:title="${l.name}" target="_blank" rel="noopener">
|
||||
<span class="jn-quick-ico">
|
||||
<th:block th:switch="${stat.index % 6}">
|
||||
<svg th:case="0" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<rect x="3.5" y="3.5" width="7" height="7" rx="1.2"></rect>
|
||||
<rect x="13.5" y="3.5" width="7" height="7" rx="1.2"></rect>
|
||||
<rect x="3.5" y="13.5" width="7" height="7" rx="1.2"></rect>
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="1.2"></rect>
|
||||
</svg>
|
||||
<svg th:case="1" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<rect x="3" y="8" width="18" height="11" rx="2"></rect>
|
||||
<path d="M9 8V6.5A1.5 1.5 0 0 1 10.5 5h3A1.5 1.5 0 0 1 15 6.5V8"></path>
|
||||
<path d="M3 12.5h18"></path>
|
||||
</svg>
|
||||
<svg th:case="2" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H19v14H5.5A1.5 1.5 0 0 0 4 19.5v-14z"></path>
|
||||
<path d="M4 19.5A1.5 1.5 0 0 1 5.5 18H19v2H5.5"></path>
|
||||
<path d="M8 8h7M8 11.5h5"></path>
|
||||
</svg>
|
||||
<svg th:case="3" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<rect x="3" y="4.5" width="18" height="12" rx="1.5"></rect>
|
||||
<path d="M9 20h6M12 16.5V20"></path>
|
||||
<path d="M10 9l2.5 3L15 9" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
<svg th:case="4" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<ellipse cx="12" cy="6" rx="7" ry="2.8"></ellipse>
|
||||
<path d="M5 6v6c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8V6"></path>
|
||||
<path d="M5 12v6c0 1.5 3.1 2.8 7 2.8s7-1.3 7-2.8v-6"></path>
|
||||
</svg>
|
||||
<svg th:case="*" viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="1.8" focusable="false" aria-hidden="true">
|
||||
<rect x="2.8" y="5.5" width="18.4" height="13" rx="2.2"></rect>
|
||||
<path d="M2.8 9.8h18.4"></path>
|
||||
<path d="M6.5 14.5h5"></path>
|
||||
</svg>
|
||||
</th:block>
|
||||
</span>
|
||||
<span class="jn-quick-name" th:text="${l.name}">快捷入口</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</th:block>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空态(各形态引用时守卫必须包在 th:block 外层:th:replace 优先级 100 先于 th:if 300 求值) -->
|
||||
<div class="jn-empty" th:fragment="empty(block)">
|
||||
<p>栏目暂无内容</p>
|
||||
<p class="jn-empty-hint">内容发布后将在此展示</p>
|
||||
</div>
|
||||