Compare commits
35
Commits
32448f097e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfad02a43 | ||
|
|
ea14dc6f82 | ||
|
|
ed0c752f4a | ||
|
|
55d80e21a4 | ||
|
|
84224b31bb | ||
|
|
f591d06c35 | ||
|
|
da39bb5066 | ||
|
|
fbcf783d3b | ||
|
|
a9e4240bb3 | ||
|
|
efd3cb30f9 | ||
|
|
4cb435b715 | ||
|
|
089cb54011 | ||
|
|
c60f9dffb7 | ||
|
|
19e9c18c96 | ||
|
|
854204d1c0 | ||
|
|
bc794ccce3 | ||
|
|
82d6ff4602 | ||
|
|
d2df12b91e | ||
|
|
52433f06a2 | ||
|
|
085c284829 | ||
|
|
c44c35bf2a | ||
|
|
8839ef8a5f | ||
|
|
89a5a346d3 | ||
|
|
649ad66264 | ||
|
|
1cb95225a6 | ||
|
|
e4b843cf00 | ||
|
|
9e28092a8b | ||
|
|
39454e4efb | ||
|
|
98d640faba | ||
|
|
f4b1749377 | ||
|
|
a666e13f56 | ||
|
|
12a492fc55 | ||
|
|
338ffb56dd | ||
|
|
536bd6cea4 | ||
|
|
5da6f7ee5d |
@@ -25,7 +25,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl(alpine)
|
- name: 安装 git / curl(alpine)
|
||||||
run: apk add --no-cache git curl
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -55,18 +58,24 @@ jobs:
|
|||||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f mavenbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name mavenbuild \
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" \
|
||||||
--network infra-infra-net \
|
--network infra-infra-net \
|
||||||
-v /opt/docker/projman-m2:/root/.m2 \
|
-v /opt/docker/projman-m2:/root/.m2 \
|
||||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||||
docker cp "${PWD}/." mavenbuild:/build/
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||||
mkdir -p "${PWD}/target"
|
mkdir -p "${PWD}/target"
|
||||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||||
docker rm -f mavenbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
|
|
||||||
- name: 构建并推送镜像
|
- name: 构建并推送镜像
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
<description>yh-platform 高校门户 CMS(管理端 API + Thymeleaf 门户 SSR)</description>
|
<description>yh-platform 高校门户 CMS(管理端 API + Thymeleaf 门户 SSR)</description>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>1.8</java.version>
|
<java.version>1.8</java.version>
|
||||||
|
<!-- 测试执行开关:默认关闭(*IT 依赖本地 MySQL/Redis,详见 build 内 surefire 注释);
|
||||||
|
需要跑测试时用 -Dskip.tests=false 临时打开 -->
|
||||||
|
<skip.tests>true</skip.tests>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
@@ -100,11 +103,14 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- 单测专用:验证方案 §12「缓存切 Redis 仅改配置」的演进承诺(生产仍为 Caffeine,零影响) -->
|
<!-- 入站验签走中央密钥链(注册表模式 yh.app-auth.trust-registry)必须编译期引入 data-redis:
|
||||||
|
yh-app-auth 的该依赖是 optional、不传递(Notice/flow-center/svc-hall 同款,2026-10-05 自
|
||||||
|
test scope 提升)。副作用是 RedisHealthIndicator 会被装配,故 yml 里关掉
|
||||||
|
management.health.redis.enabled,防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。
|
||||||
|
RedisCacheEvolutionIT(缓存演进验证)继续复用本依赖。 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
@@ -120,6 +126,14 @@
|
|||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-surefire-plugin</artifactId>
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
|
<!--
|
||||||
|
测试执行默认关闭(2026-09-15):*IT 依赖本地 MySQL/Redis,缺中间件时成片报
|
||||||
|
Communications link failure / 上下文加载失败(353 例里约 262 例),噪音掩盖真信号;
|
||||||
|
CI 构建本就是 mvn -DskipTests package,关掉后本地 mvn test/package 不再空跑约 20 分钟。
|
||||||
|
只跳过「执行」,测试源码仍会编译,接口漂移仍能在编译期暴露。
|
||||||
|
需要跑时临时覆盖:mvn test -Dskip.tests=false
|
||||||
|
-->
|
||||||
|
<skipTests>${skip.tests}</skipTests>
|
||||||
<includes>
|
<includes>
|
||||||
<include>**/*Test.java</include>
|
<include>**/*Test.java</include>
|
||||||
<include>**/*IT.java</include>
|
<include>**/*IT.java</include>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
|
||||||
|
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
|
||||||
|
-- 范围:cms_* 八表全空(站点/栏目/文章/轮播/友链/附件/事项/事项字典),AUTO_INCREMENT 归位
|
||||||
|
-- 不动的东西:
|
||||||
|
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
|
||||||
|
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
|
||||||
|
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
|
||||||
|
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
|
||||||
|
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"各表为空",本脚本正好满足)。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
TRUNCATE TABLE cms_attachment;
|
||||||
|
TRUNCATE TABLE cms_banner;
|
||||||
|
TRUNCATE TABLE cms_link;
|
||||||
|
TRUNCATE TABLE cms_article;
|
||||||
|
TRUNCATE TABLE cms_category;
|
||||||
|
TRUNCATE TABLE cms_service_item;
|
||||||
|
TRUNCATE TABLE cms_service_dict;
|
||||||
|
TRUNCATE TABLE cms_site;
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
-- 验证:八表应全为 0
|
||||||
|
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
|
||||||
|
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
|
||||||
|
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
|
||||||
|
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
|
||||||
|
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
|
||||||
|
UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment
|
||||||
|
UNION ALL SELECT 'cms_service_item', COUNT(*) FROM cms_service_item
|
||||||
|
UNION ALL SELECT 'cms_service_dict', COUNT(*) FROM cms_service_dict;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 可选(不在 SQL 内):清掉测试期上传的物理文件,只留种子静态资产。
|
||||||
|
-- 保留:D:/bsch/cms/upload/seed/ (cms_seed_local.sql 的配图依赖它)
|
||||||
|
-- 删除:seed/ 之外的目录与散文件(测试期真实上传的图片/PDF 均为孤儿)
|
||||||
|
-- Git Bash 下参考:
|
||||||
|
-- find /d/bsch/cms/upload -mindepth 1 -maxdepth 1 ! -name seed -exec rm -rf {} +
|
||||||
|
-- 主题包如需一并重置(连派生皮肤一起清掉,回到内置包):
|
||||||
|
-- rm -rf <服务运行目录>/data/themes (重启服务后仅剩 classpath 内置包)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径)
|
-- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径)
|
||||||
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
|
INSERT INTO cms_site (id, code, path, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
|
||||||
(1, 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default',
|
(1, 'demo', 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default',
|
||||||
'{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}',
|
'{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}',
|
||||||
1, 'cmssuper', NOW());
|
1, 'cmssuper', NOW());
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ WHERE code = 'demo';
|
|||||||
UPDATE cms_banner SET status = 0 WHERE id = 5;
|
UPDATE cms_banner SET status = 0 WHERE id = 5;
|
||||||
|
|
||||||
-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生)
|
-- 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
|
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()),
|
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'academic-navy', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
|
||||||
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'default', '{"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
|
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()),
|
(61, 6, 0, '学院概况', 2, NULL, '<p>机电学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW()),
|
||||||
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
|
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- R1 迁移脚本:学校主站编码改 5 位数字码(1 + 学校ID左补零至4位)
|
||||||
|
-- 例:sch_id=13 → code/path = 10013;幂等(只处理编码≠目标值的行)
|
||||||
|
-- 前置:W4 嵌套 path 机制已落地(cms_site.path 列已回填,uk_site_path 已建)
|
||||||
|
-- 旧链接提示:执行后 /portal/sch-13/ 不再解析(301 兼容仅覆盖子站按 code
|
||||||
|
-- 回查,主站不在其列);已对外发链需在 Caddy 层加旧前缀 301 或接受断链。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 0) 前置校验:先人工看一眼存量主站
|
||||||
|
-- ① 同一 sch_id 挂多个顶层站 → 改码后触发 uk_site_path 冲突,需先合并/停用;
|
||||||
|
-- ② sch_id 为空的顶层站(如演示站)会被本脚本跳过、保持原编码。
|
||||||
|
SELECT id, code, sch_id, path, status FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
|
||||||
|
|
||||||
|
-- 1) 顶层主站:code 与 path 一起改为目标 5 位码(sch_id 全局唯一 ⇒ 天然满足
|
||||||
|
-- uk_site_path / uk_site_parent_code;sch_id 为空的顶层站跳过)
|
||||||
|
UPDATE cms_site
|
||||||
|
SET code = CONCAT('1', LPAD(sch_id, 4, '0')),
|
||||||
|
path = CONCAT('1', LPAD(sch_id, 4, '0'))
|
||||||
|
WHERE (parent_site_id = 0 OR parent_site_id IS NULL)
|
||||||
|
AND sch_id IS NOT NULL
|
||||||
|
AND code <> CONCAT('1', LPAD(sch_id, 4, '0'));
|
||||||
|
|
||||||
|
-- 2) 子站 path 级联重算(code 不动,仅换父前缀;幂等)
|
||||||
|
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
|
||||||
|
SET s.path = CONCAT(p.path, '/', s.code)
|
||||||
|
WHERE s.parent_site_id > 0
|
||||||
|
AND s.path <> CONCAT(p.path, '/', s.code);
|
||||||
|
|
||||||
|
-- 3) 校验(两条都应返回 0 行)
|
||||||
|
SELECT id, code, path FROM cms_site WHERE path = '' OR path IS NULL;
|
||||||
|
SELECT id, code, path FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
|
||||||
|
|
||||||
|
-- 4) 收尾:缓存为进程内 Caffeine(站点快照 TTL 30min、旧 key 自然过期);
|
||||||
|
-- 想立即生效可在站点管理保存一次任一站点(触发 SiteContentChangedEvent 清缓存),
|
||||||
|
-- 或重启 cms 容器。
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- R2 升级脚本:事项库两表(事项库与办事大厅详细设计 §2.5,幂等)
|
||||||
|
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
|
||||||
|
-- 顺序:加表(CREATE IF NOT EXISTS 幂等)→ 索引守卫补建(幂等)→ 校验
|
||||||
|
-- 说明:MySQL 无 ADD INDEX IF NOT EXISTS,建表内联索引;存量库若只建了表缺索引
|
||||||
|
-- (含 r2 首版之后新增的 idx_svc_item_portal),由第 3 段守卫块自动补建,可反复重跑。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1) 事项主表
|
||||||
|
CREATE TABLE IF NOT EXISTS `cms_service_item` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
|
`item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)',
|
||||||
|
`title` VARCHAR(200) NOT NULL COMMENT '事项名称',
|
||||||
|
`summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)',
|
||||||
|
`object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)',
|
||||||
|
`category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)',
|
||||||
|
`dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)',
|
||||||
|
`dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)',
|
||||||
|
`handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下',
|
||||||
|
`online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)',
|
||||||
|
`handle_place` VARCHAR(200) NULL COMMENT '办理地点',
|
||||||
|
`handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")',
|
||||||
|
`promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)',
|
||||||
|
`contact_phone` VARCHAR(64) NULL COMMENT '咨询电话',
|
||||||
|
`supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话',
|
||||||
|
`process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)',
|
||||||
|
`material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)',
|
||||||
|
`notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)',
|
||||||
|
`faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)',
|
||||||
|
`notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)',
|
||||||
|
`rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)',
|
||||||
|
`cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)',
|
||||||
|
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架',
|
||||||
|
`publish_time` DATETIME NULL COMMENT '发布时间',
|
||||||
|
`offline_time` DATETIME NULL COMMENT '下架时间',
|
||||||
|
`view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)',
|
||||||
|
`ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)',
|
||||||
|
`create_by` VARCHAR(64) NULL,
|
||||||
|
`create_time` DATETIME NULL,
|
||||||
|
`update_by` VARCHAR(64) NULL,
|
||||||
|
`update_time` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`),
|
||||||
|
KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`),
|
||||||
|
KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`),
|
||||||
|
KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`),
|
||||||
|
KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`),
|
||||||
|
KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)';
|
||||||
|
|
||||||
|
-- 2) 事项字典表
|
||||||
|
CREATE TABLE IF NOT EXISTS `cms_service_dict` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
|
`dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别',
|
||||||
|
`dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)',
|
||||||
|
`dict_name` VARCHAR(128) NOT NULL COMMENT '展示名',
|
||||||
|
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)',
|
||||||
|
`ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)',
|
||||||
|
`create_by` VARCHAR(64) NULL,
|
||||||
|
`create_time` DATETIME NULL,
|
||||||
|
`update_by` VARCHAR(64) NULL,
|
||||||
|
`update_time` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`),
|
||||||
|
KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';
|
||||||
|
|
||||||
|
-- 3) 索引守卫补建(仅当表已存在但缺索引时生效;幂等,可反复重跑)
|
||||||
|
-- idx_svc_item_portal:门户服务中心列表 ORDER BY sort_no ASC, publish_time DESC, id DESC 的对症索引
|
||||||
|
-- (原 idx_svc_item_list 前导列含门户未过滤的 online_flag,无法用于该排序 → 必然 filesort)
|
||||||
|
SET @db := DATABASE();
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'uk_svc_item_code') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD UNIQUE KEY uk_svc_item_code (site_id, item_code)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'idx_svc_item_list') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_list (site_id, status, online_flag, sort_no)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'idx_svc_item_obj') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_obj (site_id, status, object_key)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'idx_svc_item_cat') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_cat (site_id, status, category_key)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'idx_svc_item_dept') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_dept (site_id, status, dept_group_id)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_item'
|
||||||
|
AND index_name = 'idx_svc_item_portal') = 0,
|
||||||
|
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_portal (site_id, status, sort_no ASC, publish_time DESC, id DESC)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_dict'
|
||||||
|
AND index_name = 'uk_svc_dict') = 0,
|
||||||
|
'ALTER TABLE cms_service_dict ADD UNIQUE KEY uk_svc_dict (site_id, dict_type, dict_key)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
|
||||||
|
WHERE table_schema = @db AND table_name = 'cms_service_dict'
|
||||||
|
AND index_name = 'idx_svc_dict_list') = 0,
|
||||||
|
'ALTER TABLE cms_service_dict ADD KEY idx_svc_dict_list (site_id, dict_type, status, sort_no)', 'DO 0');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 4) 校验
|
||||||
|
-- 4.1) 应返回 0 行(两表存在且注释非空)
|
||||||
|
SELECT TABLE_NAME FROM information_schema.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME IN ('cms_service_item', 'cms_service_dict')
|
||||||
|
AND TABLE_COMMENT = '';
|
||||||
|
|
||||||
|
-- 4.2) 应返回 10 行(两表全部索引就位:主键 2 + 唯一键 2 + 普通索引 6)
|
||||||
|
SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name IN ('cms_service_item', 'cms_service_dict')
|
||||||
|
GROUP BY table_name, index_name
|
||||||
|
ORDER BY table_name, index_name;
|
||||||
@@ -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,31 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- W4 升级脚本:cms_site 嵌套 path(《功能修订方案.md》§8 / 附录 A,幂等)
|
||||||
|
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
|
||||||
|
-- 顺序:加列 → 回填(先顶层后子站,两层固定一轮)→ 约束切换 → 校验
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 1) 加列(存量默认空串,随后回填;已加列则跳过)
|
||||||
|
ALTER TABLE cms_site ADD COLUMN path VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识';
|
||||||
|
|
||||||
|
-- 2) 回填:先顶层后子站(幂等:仅处理 path='')
|
||||||
|
UPDATE cms_site SET path = code WHERE (parent_site_id = 0 OR parent_site_id IS NULL) AND path = '';
|
||||||
|
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
|
||||||
|
SET s.path = CONCAT(p.path, '/', s.code)
|
||||||
|
WHERE s.parent_site_id > 0 AND s.path = '';
|
||||||
|
|
||||||
|
-- 3) 约束切换(存量数据天然满足:code 曾全局唯一 ⇒ path 唯一)
|
||||||
|
ALTER TABLE cms_site DROP INDEX uk_site_code;
|
||||||
|
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_path (path);
|
||||||
|
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_parent_code (parent_site_id, code);
|
||||||
|
|
||||||
|
-- 4) 校验(应返回 0 行)
|
||||||
|
SELECT id, code, path FROM cms_site WHERE path = '';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 附:R1 预留模板——学校主站编码改学校 ID(1000xxx)级联重算子站 path
|
||||||
|
-- (切换生成规则后执行;把 {OLD_PREFIX} 替换为旧前缀、按新编码规则回填顶层)
|
||||||
|
-- UPDATE cms_site SET path = CONCAT('1000', sch_id) WHERE parent_site_id = 0 AND code LIKE '{OLD_PREFIX}%';
|
||||||
|
-- UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
|
||||||
|
-- SET s.path = CONCAT(p.path, '/', s.code)
|
||||||
|
-- WHERE s.parent_site_id > 0;
|
||||||
|
-- ============================================================
|
||||||
@@ -51,7 +51,8 @@ public class CacheConfig implements CachingConfigurer {
|
|||||||
manager.setCaches(Arrays.asList(
|
manager.setCaches(Arrays.asList(
|
||||||
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
|
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
|
||||||
new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()),
|
new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()),
|
||||||
new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build())));
|
new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()),
|
||||||
|
new CaffeineCache(CacheNames.SERVICE_ITEM, caffeine(Duration.ofMinutes(5)).build())));
|
||||||
return manager;
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ package com.yunhai.cms.config;
|
|||||||
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
|
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
|
||||||
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
|
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
|
||||||
* 失效事件 = 该站点文章发布/下架/修改</li>
|
* 失效事件 = 该站点文章发布/下架/修改</li>
|
||||||
|
* <li>{@link #SERVICE_ITEM}:itemId → 事项指南页模型;filters:{siteId} → 三组筛选项+计数,
|
||||||
|
* TTL 5min±60s(itemId 不存在时 null 哨兵 30s 防穿透);
|
||||||
|
* 失效事件 = 事项发布/修改/下架/删除、字典变更(ServiceItemChangedEvent,仅事项域,
|
||||||
|
* 不触碰 cms:article/cms:list/cms:site)</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public final class CacheNames {
|
public final class CacheNames {
|
||||||
@@ -20,6 +24,8 @@ public final class CacheNames {
|
|||||||
public static final String ARTICLE = "cms:article";
|
public static final String ARTICLE = "cms:article";
|
||||||
/** 门户栏目列表页 */
|
/** 门户栏目列表页 */
|
||||||
public static final String LIST = "cms:list";
|
public static final String LIST = "cms:list";
|
||||||
|
/** 事项指南页模型 + 筛选项(key=itemId / filters:{siteId},事项库 §3.6) */
|
||||||
|
public static final String SERVICE_ITEM = "cms:svcItem";
|
||||||
|
|
||||||
private CacheNames() {
|
private CacheNames() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.yunhai.cms.config;
|
||||||
|
|
||||||
|
import com.yunhai.cms.theme.ThemeRegistry;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.thymeleaf.IEngineConfiguration;
|
||||||
|
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||||
|
import org.thymeleaf.templateresolver.ITemplateResolver;
|
||||||
|
import org.thymeleaf.templateresource.FileTemplateResource;
|
||||||
|
import org.thymeleaf.templateresource.ITemplateResource;
|
||||||
|
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W1(G7 关键,方案 §5.4 步骤 3):为 Thymeleaf 追加外部主题目录的文件解析器——
|
||||||
|
* 视图名 {@code themes/{外部包}/home} 改写为文件路径 {cms.theme.dir}/{外部包}/home.html;
|
||||||
|
* 内置包名不改写(返回不存在资源,checkExistence=true 时本解析器跳过,由 classpath 解析器处理)。
|
||||||
|
*
|
||||||
|
* <p>Spring 的 orderedStream 只认 {@code @Order}/{@code Ordered}(Thymeleaf 的 getOrder() 不参与
|
||||||
|
* Spring 排序):必须排最前,否则 Boot 默认 SpringResourceTemplateResolver(checkExistence=false)
|
||||||
|
* 会抢先 claim 导致外部包模板解析不到。cacheable=true,registry.reload() 时经
|
||||||
|
* TemplateEngine.clearTemplateCache() 清缓存;目录经 registry.getThemeDir() 动态读取
|
||||||
|
* (测试可指向临时目录)。外部包门户 SSR 的前提(T-W1-19)。
|
||||||
|
*
|
||||||
|
* <p>Thymeleaf 3.0.15 的 {@code computeTemplateResource} 后两个参数是
|
||||||
|
* (resourceName=template+suffix, characterEncoding),非 suffix/markuper——路径由 template 自行拼接。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
public class ThemeTemplateResolverConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@org.springframework.core.annotation.Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public ITemplateResolver externalThemeTemplateResolver(final ThemeRegistry registry,
|
||||||
|
final org.springframework.beans.factory.ObjectProvider<org.thymeleaf.TemplateEngine> engineProvider) {
|
||||||
|
FileTemplateResolver resolver = new FileTemplateResolver() {
|
||||||
|
@Override
|
||||||
|
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
|
||||||
|
String ownerTemplate, String template,
|
||||||
|
String resourceName, String characterEncoding,
|
||||||
|
java.util.Map<String, Object> templateResolutionAttributes) {
|
||||||
|
String t = template.replace('\\', '/');
|
||||||
|
if (t.startsWith("themes/") && !t.equals("themes/")) {
|
||||||
|
String rest = t.substring("themes/".length());
|
||||||
|
int slash = rest.indexOf('/');
|
||||||
|
String pkg = slash > 0 ? rest.substring(0, slash) : rest;
|
||||||
|
if (ThemeRegistry.SOURCE_EXTERNAL.equals(registry.sourceOf(pkg))) {
|
||||||
|
String rel = (rest.endsWith(".html") ? rest : rest + ".html")
|
||||||
|
.replace('/', java.io.File.separatorChar);
|
||||||
|
String path = Paths.get(normalize(registry.getThemeDir()), rel).toString();
|
||||||
|
return new FileTemplateResource(path, characterEncoding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 内置包名等:返回不存在资源(checkExistence=true → 本解析器跳过,回落 classpath 解析器)
|
||||||
|
return new FileTemplateResource("__nonexistent_external__/themes/" + t, characterEncoding);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
resolver.setCharacterEncoding("UTF-8");
|
||||||
|
resolver.setCheckExistence(true);
|
||||||
|
resolver.setCacheable(true);
|
||||||
|
registry.addReloadListener(() -> {
|
||||||
|
try {
|
||||||
|
org.thymeleaf.TemplateEngine engine = engineProvider.getIfAvailable();
|
||||||
|
if (engine != null) {
|
||||||
|
engine.clearTemplateCache();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[cms] reload 清模板缓存失败:{}", e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log.info("[cms] 外部主题模板解析器已注册(仅外部包命中,@Order 最优先,内置包跳过回落 classpath)");
|
||||||
|
return resolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String dir) {
|
||||||
|
String d = dir == null ? "" : dir.trim().replace('\\', '/');
|
||||||
|
while (d.endsWith("/")) {
|
||||||
|
d = d.substring(0, d.length() - 1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ public class AttachmentController {
|
|||||||
|
|
||||||
private final AttachmentService attachmentService;
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
|
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
public AttachmentVo upload(BoaUser authUserWithDept,
|
public AttachmentVo upload(BoaUser authUserWithDept,
|
||||||
@RequestParam("siteId") Integer siteId,
|
@RequestParam("siteId") Integer siteId,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.yunhai.cms.controller.api;
|
package com.yunhai.cms.controller.api;
|
||||||
|
|
||||||
import com.yunhai.cms.service.impl.ThemeService;
|
import com.yunhai.cms.service.impl.ThemeService;
|
||||||
|
import com.yunhai.cms.theme.ThemeInfo;
|
||||||
|
import com.yunhai.cms.theme.ThemePackageService;
|
||||||
import com.yunhai.cms.theme.ThemeRegistry;
|
import com.yunhai.cms.theme.ThemeRegistry;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.CacheControl;
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -12,7 +13,14 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -33,12 +41,61 @@ public class ThemeController {
|
|||||||
|
|
||||||
private final ThemeService themeService;
|
private final ThemeService themeService;
|
||||||
private final ThemeRegistry themeRegistry;
|
private final ThemeRegistry themeRegistry;
|
||||||
|
private final ThemePackageService themePackageService;
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
|
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
|
||||||
return themeService.list();
|
return themeService.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册。
|
||||||
|
* 任务四:title/description 选填,落位前改写包内 theme.json(留空沿用包内声明) */
|
||||||
|
@PostMapping("/upload")
|
||||||
|
public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept,
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "title", required = false) String title,
|
||||||
|
@RequestParam(value = "description", required = false) String description) {
|
||||||
|
ThemeInfo info = themePackageService.upload(authUserWithDept, file, title, description);
|
||||||
|
return themeService.voOf(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除);任务四支持同步命名 */
|
||||||
|
@PostMapping("/derive")
|
||||||
|
public com.yunhai.cms.model.vo.ThemeVo derive(BoaUser authUserWithDept,
|
||||||
|
@RequestBody com.yunhai.cms.model.dto.ThemeDeriveRequest req) {
|
||||||
|
ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName(),
|
||||||
|
req.getTitle(), req.getDescription());
|
||||||
|
return themeService.voOf(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 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.getParamValues());
|
||||||
|
return themeService.voOf(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1(G5):删除外部包(被用站点拒绝;内置不可删) */
|
||||||
|
@DeleteMapping("/{name}")
|
||||||
|
public java.util.Map<String, Object> remove(BoaUser authUserWithDept, @PathVariable("name") String name) {
|
||||||
|
themePackageService.remove(authUserWithDept, name);
|
||||||
|
java.util.Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1(G2/B4):热重载(运维直放目录后生效;default 缺失报错且旧集合不丢) */
|
||||||
|
@PostMapping("/reload")
|
||||||
|
public List<com.yunhai.cms.model.vo.ThemeVo> reload(BoaUser authUserWithDept) {
|
||||||
|
themePackageService.reload(authUserWithDept);
|
||||||
|
return themeService.list();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{name}/asset/**")
|
@GetMapping("/{name}/asset/**")
|
||||||
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
|
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
@@ -49,7 +106,7 @@ public class ThemeController {
|
|||||||
if (path == null || path.isEmpty() || path.contains("..")) {
|
if (path == null || path.isEmpty() || path.contains("..")) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path);
|
Resource resource = themeRegistry.resource(name, "assets/" + path);
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ public class ThemePreviewController {
|
|||||||
private final PortalPageRenderer pageRenderer;
|
private final PortalPageRenderer pageRenderer;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
|
|
||||||
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表) */
|
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表)。
|
||||||
|
* W4:内容源站点参数 siteCode→sitePath(嵌套路径语义,siteSnapshotByPath 解析)。 */
|
||||||
@GetMapping("/preview")
|
@GetMapping("/preview")
|
||||||
public String preview(@RequestParam("siteCode") String siteCode,
|
public String preview(@RequestParam("sitePath") String sitePath,
|
||||||
@RequestParam("theme") String theme,
|
@RequestParam("theme") String theme,
|
||||||
@RequestParam(value = "page", required = false, defaultValue = "home") String page,
|
@RequestParam(value = "page", required = false, defaultValue = "home") String page,
|
||||||
BoaUser authUserWithDept,
|
BoaUser authUserWithDept,
|
||||||
@@ -51,10 +52,10 @@ public class ThemePreviewController {
|
|||||||
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
|
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
|
||||||
}
|
}
|
||||||
ThemeInfo info = themeService.requireRegistered(theme);
|
ThemeInfo info = themeService.requireRegistered(theme);
|
||||||
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
|
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
"内容源站点不存在或已停用:" + siteCode);
|
"内容源站点不存在或已停用:" + sitePath);
|
||||||
}
|
}
|
||||||
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
|
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
|
||||||
if (authUserWithDept == null) {
|
if (authUserWithDept == null) {
|
||||||
@@ -66,6 +67,6 @@ public class ThemePreviewController {
|
|||||||
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
|
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
|
||||||
previewSite.setThemeParams(params);
|
previewSite.setThemeParams(params);
|
||||||
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params));
|
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params));
|
||||||
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, siteCode, theme), model);
|
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, sitePath, theme), model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.yunhai.cms.controller.oapi.site;
|
||||||
|
|
||||||
|
import com.yunhai.cms.entity.CmsSite;
|
||||||
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
|
import com.yunhai.cms.utils.ExtJsonUtils;
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点目录开放接口(/oapi/site/list,appKey 签名访问;V2.0 Part C.3)。
|
||||||
|
* 消费方=办事大厅 svc-hall(CmsSiteDirectoryImpl:resolveSite 反解 siteCode→schId、
|
||||||
|
* 站点选择器按校拉取;本地缓存 5min)。schId 可选:
|
||||||
|
* 不传=全部启用站点(跨校全量,供 siteCode 反解 siteCode→schId,旧办件仍可定位);
|
||||||
|
* 传了=按校过滤且<b>仅主站</b>(parentSiteId=0):院系子站不接入办事大厅(2026-09-17 口径收窄,
|
||||||
|
* 办事大厅按校接入一间学校只对应主站一个站点),SiteOapiIT 冻结该契约。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/oapi/site")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SiteOapiController {
|
||||||
|
|
||||||
|
private final CmsSiteRepository siteRepository;
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
public List<SiteOptionVo> list(@RequestParam(value = "schId", required = false) Integer schId) {
|
||||||
|
if (schId == null) {
|
||||||
|
return siteRepository.findByStatusOrderByIdAsc(1).stream()
|
||||||
|
.map(SiteOapiController::toVo)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
if (schId <= 0) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "schId 非法");
|
||||||
|
}
|
||||||
|
return siteRepository.findBySchId(schId).stream()
|
||||||
|
.filter(site -> site.getStatus() != null && site.getStatus() == 1)
|
||||||
|
// 仅主站:院系子站不接入办事大厅(反解全量不受影响)
|
||||||
|
.filter(site -> site.getParentSiteId() == null || site.getParentSiteId() == 0)
|
||||||
|
.map(SiteOapiController::toVo)
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SiteOptionVo toVo(CmsSite site) {
|
||||||
|
SiteOptionVo vo = new SiteOptionVo();
|
||||||
|
vo.setSiteCode(site.getCode());
|
||||||
|
vo.setName(site.getName());
|
||||||
|
vo.setSchId(site.getSchId() == null ? null : site.getSchId().longValue());
|
||||||
|
// T1 主题联动(2026-10-03):theme 空=default;themeParams=config_json.theme_params
|
||||||
|
// 原样键值(解析写法对齐 PortalSiteSnapshot.of),未配=空对象(消费方免判空两写)
|
||||||
|
vo.setTheme(site.getTheme() == null || site.getTheme().isEmpty()
|
||||||
|
? "default" : site.getTheme());
|
||||||
|
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
|
||||||
|
Object tp = config == null ? null : config.get("theme_params");
|
||||||
|
Map<String, String> params = new LinkedHashMap<>();
|
||||||
|
if (tp instanceof Map) {
|
||||||
|
for (Map.Entry<?, ?> e : ((Map<?, ?>) tp).entrySet()) {
|
||||||
|
if (e.getKey() != null && e.getValue() != null) {
|
||||||
|
params.put(String.valueOf(e.getKey()), String.valueOf(e.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vo.setThemeParams(params);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点选项(siteCode 为办事大厅侧定位键;schId 用于权限与租户定界;
|
||||||
|
* theme/themeParams raw 透传——消费方(svc-hall hall 用户面)按皮肤声明自行过滤) */
|
||||||
|
@Data
|
||||||
|
public static class SiteOptionVo {
|
||||||
|
private String siteCode;
|
||||||
|
private String name;
|
||||||
|
private Long schId;
|
||||||
|
/** CMS 皮肤包名(空已兜底为 default) */
|
||||||
|
private String theme;
|
||||||
|
/** CMS config_json.theme_params 原样键值(未配置=空对象) */
|
||||||
|
private Map<String, String> themeParams = new LinkedHashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@@ -40,20 +39,33 @@ public class PortalAssetController {
|
|||||||
public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
|
public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
|
||||||
@PathVariable("theme") String theme,
|
@PathVariable("theme") String theme,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
|
return doAsset(siteCode, theme, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/assets/{theme}/**(层级固定两层) */
|
||||||
|
@GetMapping("/portal/{p}/{s}/assets/{theme}/**")
|
||||||
|
public ResponseEntity<Resource> assetNested(@PathVariable("p") String p,
|
||||||
|
@PathVariable("s") String s,
|
||||||
|
@PathVariable("theme") String theme,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
return doAsset(p + "/" + s, theme, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Resource> doAsset(String sitePath, String theme, HttpServletRequest request) {
|
||||||
// 主题段必须已注册(兼防路径注入:未注册名一律 404)
|
// 主题段必须已注册(兼防路径注入:未注册名一律 404)
|
||||||
if (!themeRegistry.contains(theme)) {
|
if (!themeRegistry.contains(theme)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
|
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
String path = extractRelativePath(request, siteCode, theme);
|
String path = extractRelativePath(request, sitePath, theme);
|
||||||
if (path == null || path.isEmpty() || path.contains("..")) {
|
if (path == null || path.isEmpty() || path.contains("..")) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
Resource resource = new ClassPathResource(
|
// W1(T-W1-20):内置→ClassPathResource、外部→FileSystemResource,统一走 Registry 定位
|
||||||
"templates/themes/" + theme + "/assets/" + path);
|
Resource resource = themeRegistry.resource(theme, "assets/" + path);
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -68,10 +80,11 @@ public class PortalAssetController {
|
|||||||
/**
|
/**
|
||||||
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
|
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
|
||||||
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。
|
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。
|
||||||
|
* W4:前缀用完整 sitePath(嵌套形态含两层段)。
|
||||||
*/
|
*/
|
||||||
private String extractRelativePath(HttpServletRequest request, String siteCode, String theme) {
|
private String extractRelativePath(HttpServletRequest request, String sitePath, String theme) {
|
||||||
String uri = request.getRequestURI();
|
String uri = request.getRequestURI();
|
||||||
String prefix = request.getContextPath() + "/portal/" + siteCode + "/assets/" + theme + "/";
|
String prefix = request.getContextPath() + "/portal/" + sitePath + "/assets/" + theme + "/";
|
||||||
int index = uri.indexOf(prefix);
|
int index = uri.indexOf(prefix);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ import com.yunhai.cms.model.portal.PortalBanner;
|
|||||||
import com.yunhai.cms.model.portal.PortalCategory;
|
import com.yunhai.cms.model.portal.PortalCategory;
|
||||||
import com.yunhai.cms.model.portal.PortalListData;
|
import com.yunhai.cms.model.portal.PortalListData;
|
||||||
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
||||||
|
import com.yunhai.cms.entity.CmsSite;
|
||||||
import com.yunhai.cms.repository.CmsArticleRepository;
|
import com.yunhai.cms.repository.CmsArticleRepository;
|
||||||
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
import com.yunhai.cms.service.impl.PortalQueryService;
|
import com.yunhai.cms.service.impl.PortalQueryService;
|
||||||
import com.yunhai.cms.service.impl.ViewCounterService;
|
import com.yunhai.cms.service.impl.ViewCounterService;
|
||||||
|
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
|
||||||
|
import com.yunhai.cms.svcitem.service.SvcItemQuery;
|
||||||
import com.yunhai.cms.utils.PortalUrlHelper;
|
import com.yunhai.cms.utils.PortalUrlHelper;
|
||||||
|
import com.yunhai.cms.utils.SvcHallLinks;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -19,6 +27,7 @@ import org.springframework.data.domain.PageRequest;
|
|||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -28,6 +37,7 @@ import javax.servlet.http.HttpServletResponse;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -45,11 +55,15 @@ public class PortalController {
|
|||||||
private static final int HOME_BLOCK_SIZE = 8;
|
private static final int HOME_BLOCK_SIZE = 8;
|
||||||
/** 首页置顶区取 N 条 */
|
/** 首页置顶区取 N 条 */
|
||||||
private static final int HOME_TOP_SIZE = 6;
|
private static final int HOME_TOP_SIZE = 6;
|
||||||
|
/** 指南页"相关事项"取 N 条(事项库 §5.3) */
|
||||||
|
private static final int RELATED_ITEMS_SIZE = 6;
|
||||||
|
|
||||||
private final PortalQueryService portalQueryService;
|
private final PortalQueryService portalQueryService;
|
||||||
private final CmsArticleRepository articleRepository;
|
private final CmsArticleRepository articleRepository;
|
||||||
|
private final CmsSiteRepository siteRepository;
|
||||||
private final ViewCounterService viewCounterService;
|
private final ViewCounterService viewCounterService;
|
||||||
private final PortalPageRenderer pageRenderer;
|
private final PortalPageRenderer pageRenderer;
|
||||||
|
private final SvcItemQuery svcItemQuery;
|
||||||
|
|
||||||
@Value("${cms.portal.detail-max-age:300}")
|
@Value("${cms.portal.detail-max-age:300}")
|
||||||
private int detailMaxAge;
|
private int detailMaxAge;
|
||||||
@@ -58,11 +72,25 @@ public class PortalController {
|
|||||||
@Value("${cms.portal.base-origin:}")
|
@Value("${cms.portal.base-origin:}")
|
||||||
private String baseOrigin;
|
private String baseOrigin;
|
||||||
|
|
||||||
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5) */
|
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5)。
|
||||||
|
* W4:单段/嵌套双映射收敛到 doHome(sitePath 为唯一身份)。 */
|
||||||
@GetMapping("/portal/{siteCode}/")
|
@GetMapping("/portal/{siteCode}/")
|
||||||
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
|
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
|
||||||
HttpServletResponse response, Model model) throws Exception {
|
HttpServletResponse response, Model model) throws Exception {
|
||||||
PortalSiteSnapshot site = requireSite(siteCode, response);
|
return doHome(siteCode, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/(层级固定两层) */
|
||||||
|
@GetMapping("/portal/{p}/{s}/")
|
||||||
|
public String homeNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doHome(p + "/" + s, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doHome(String sitePath, HttpServletRequest request,
|
||||||
|
HttpServletResponse response, Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "", request, response);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -76,7 +104,23 @@ public class PortalController {
|
|||||||
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
HttpServletRequest request, HttpServletResponse response,
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
Model model) throws Exception {
|
Model model) throws Exception {
|
||||||
PortalSiteSnapshot site = requireSite(siteCode, response);
|
return doCategory(siteCode, categoryId, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/c/{categoryId} */
|
||||||
|
@GetMapping("/portal/{p}/{s}/c/{categoryId}")
|
||||||
|
public String categoryNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
@PathVariable("categoryId") Integer categoryId,
|
||||||
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doCategory(p + "/" + s, categoryId, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doCategory(String sitePath, Integer categoryId, Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "c/" + categoryId, request, response);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -100,6 +144,8 @@ public class PortalController {
|
|||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
model.addAttribute("currentCategoryId", categoryId);
|
model.addAttribute("currentCategoryId", categoryId);
|
||||||
model.addAttribute("category", category);
|
model.addAttribute("category", category);
|
||||||
|
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
|
||||||
|
pageRenderer.attachCategoryNavigation(site, categoryId, model);
|
||||||
model.addAttribute("pageTitle", seoTitle(category, site));
|
model.addAttribute("pageTitle", seoTitle(category, site));
|
||||||
model.addAttribute("seoDescription", category.getSeoDescription());
|
model.addAttribute("seoDescription", category.getSeoDescription());
|
||||||
model.addAttribute("canonical", url.category(categoryId));
|
model.addAttribute("canonical", url.category(categoryId));
|
||||||
@@ -126,7 +172,22 @@ public class PortalController {
|
|||||||
@PathVariable("articleId") Integer articleId,
|
@PathVariable("articleId") Integer articleId,
|
||||||
HttpServletRequest request, HttpServletResponse response,
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
Model model) throws Exception {
|
Model model) throws Exception {
|
||||||
PortalSiteSnapshot site = requireSite(siteCode, response);
|
return doArticle(siteCode, articleId, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/a/{articleId} */
|
||||||
|
@GetMapping("/portal/{p}/{s}/a/{articleId}")
|
||||||
|
public String articleNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
@PathVariable("articleId") Integer articleId,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doArticle(p + "/" + s, articleId, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doArticle(String sitePath, Integer articleId,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "a/" + articleId, request, response);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -150,6 +211,8 @@ public class PortalController {
|
|||||||
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
|
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
|
||||||
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
|
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
|
||||||
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
|
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
|
||||||
|
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
|
||||||
|
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
|
||||||
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
|
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
|
||||||
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
|
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
|
||||||
model.addAttribute("article", detail.withContent(
|
model.addAttribute("article", detail.withContent(
|
||||||
@@ -172,7 +235,23 @@ public class PortalController {
|
|||||||
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
HttpServletRequest request, HttpServletResponse response,
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
Model model) throws Exception {
|
Model model) throws Exception {
|
||||||
PortalSiteSnapshot site = requireSite(siteCode, response);
|
return doSearch(siteCode, q, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/search */
|
||||||
|
@GetMapping("/portal/{p}/{s}/search")
|
||||||
|
public String searchNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
@RequestParam(value = "q", required = false, defaultValue = "") String q,
|
||||||
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doSearch(p + "/" + s, q, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doSearch(String sitePath, String q, Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "search", request, response);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -208,30 +287,466 @@ public class PortalController {
|
|||||||
|
|
||||||
// ===== 内部装配 =====
|
// ===== 内部装配 =====
|
||||||
|
|
||||||
private PortalSiteSnapshot requireSite(String siteCode, HttpServletResponse response) throws Exception {
|
/**
|
||||||
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
|
* 服务中心列表(事项库 §5.1/§5.2):三维筛选 obj/cat/dept + 关键词 q + 分页。
|
||||||
|
* 筛选/分页 URL 全部后端预生成(C-2:模板禁止手拼 query);service.html 为可选模板,
|
||||||
|
* 走 M0 整页回退(缺模板时视图名/themeBase/资产主题段统一退 default)。
|
||||||
|
*/
|
||||||
|
@GetMapping("/portal/{siteCode}/service")
|
||||||
|
public String service(@PathVariable("siteCode") String siteCode,
|
||||||
|
@RequestParam(value = "obj", required = false) String obj,
|
||||||
|
@RequestParam(value = "cat", required = false) String cat,
|
||||||
|
@RequestParam(value = "dept", required = false) Integer dept,
|
||||||
|
@RequestParam(value = "q", required = false, defaultValue = "") String q,
|
||||||
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doServiceList(siteCode, obj, cat, dept, q, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/service */
|
||||||
|
@GetMapping("/portal/{p}/{s}/service")
|
||||||
|
public String serviceNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
@RequestParam(value = "obj", required = false) String obj,
|
||||||
|
@RequestParam(value = "cat", required = false) String cat,
|
||||||
|
@RequestParam(value = "dept", required = false) Integer dept,
|
||||||
|
@RequestParam(value = "q", required = false, defaultValue = "") String q,
|
||||||
|
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doServiceList(p + "/" + s, obj, cat, dept, q, page, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事项指南页(§5.3):富文本已清洗(保存期 jsoup)+ 渲染期补基准;applyUrl 三态优先级 */
|
||||||
|
@GetMapping("/portal/{siteCode}/sv/{itemId}")
|
||||||
|
public String guide(@PathVariable("siteCode") String siteCode,
|
||||||
|
@PathVariable("itemId") Integer itemId,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doGuide(siteCode, itemId, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W4 嵌套变体:/portal/{p}/{s}/sv/{itemId} */
|
||||||
|
@GetMapping("/portal/{p}/{s}/sv/{itemId}")
|
||||||
|
public String guideNested(@PathVariable("p") String p, @PathVariable("s") String s,
|
||||||
|
@PathVariable("itemId") Integer itemId,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
return doGuide(p + "/" + s, itemId, request, response, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doServiceList(String sitePath, String obj, String cat, Integer dept, String q,
|
||||||
|
Integer page, HttpServletRequest request,
|
||||||
|
HttpServletResponse response, Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "service", request, response);
|
||||||
if (site == null) {
|
if (site == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "service.html");
|
||||||
|
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
|
||||||
|
applyServiceNavHighlight(site, url, model);
|
||||||
|
|
||||||
|
SvcItemPortalQuery query = new SvcItemPortalQuery();
|
||||||
|
query.setSiteId(site.getId());
|
||||||
|
query.setObjectKey(trimToNull(obj));
|
||||||
|
query.setCategoryKey(trimToNull(cat));
|
||||||
|
query.setDeptGroupId(dept);
|
||||||
|
query.setKeyword(trimToNull(q));
|
||||||
|
query.setPage(page == null || page < 1 ? 1 : page);
|
||||||
|
|
||||||
|
// P1-4:svc-hall 停机/重启窗口(连接类异常,含 DHHttpException code=0)降级空态渲染 200,
|
||||||
|
// 不再连坐访客 500;业务性缺失语义维持 §2.3 修复后的行为(portalPage 无 code=2 分支)
|
||||||
|
PortalServiceListData serviceList;
|
||||||
|
boolean svcUnavailable;
|
||||||
|
try {
|
||||||
|
serviceList = svcItemQuery.portalPage(query);
|
||||||
|
svcUnavailable = false;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[portal] 事项服务不可达(降级空态渲染)siteId={}:{}", site.getId(), e.toString());
|
||||||
|
serviceList = new PortalServiceListData();
|
||||||
|
serviceList.setSiteId(site.getId());
|
||||||
|
serviceList.setPage(1);
|
||||||
|
serviceList.setPageSize(10);
|
||||||
|
serviceList.setTotal(0L);
|
||||||
|
serviceList.setTotalPages(0);
|
||||||
|
svcUnavailable = true;
|
||||||
|
}
|
||||||
|
model.addAttribute("svcUnavailable", svcUnavailable);
|
||||||
|
if (svcUnavailable) {
|
||||||
|
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
|
||||||
|
model.addAttribute("canonical", url.serviceList());
|
||||||
|
return "themes/" + effectiveTheme + "/service";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1
|
||||||
|
Map<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
|
||||||
|
model.addAttribute("serviceFilters",
|
||||||
|
buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters));
|
||||||
|
Map<String, Object> activeFilters = new HashMap<>();
|
||||||
|
activeFilters.put("obj", trimToNull(obj));
|
||||||
|
activeFilters.put("cat", trimToNull(cat));
|
||||||
|
activeFilters.put("dept", dept);
|
||||||
|
activeFilters.put("q", q == null ? "" : q);
|
||||||
|
activeFilters.put("clearUrl", url.serviceList());
|
||||||
|
activeFilters.put("hasAny", trimToNull(obj) != null || trimToNull(cat) != null
|
||||||
|
|| dept != null || (q != null && !q.trim().isEmpty()));
|
||||||
|
model.addAttribute("activeFilters", activeFilters);
|
||||||
|
for (int p = 1; p <= serviceList.getTotalPages(); p++) {
|
||||||
|
Map<String, Object> pageQuery = new LinkedHashMap<>(currentFilters);
|
||||||
|
if (p > 1) {
|
||||||
|
pageQuery.put("page", p);
|
||||||
|
}
|
||||||
|
serviceList.getPageUrls().add(new PortalServiceListData.PageUrl(
|
||||||
|
p, url.serviceList(pageQuery), p == serviceList.getPage()));
|
||||||
|
}
|
||||||
|
model.addAttribute("serviceList", serviceList);
|
||||||
|
|
||||||
|
model.addAttribute("url", url);
|
||||||
|
model.addAttribute("site", site);
|
||||||
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
|
model.addAttribute("themeBase", "themes/" + effectiveTheme);
|
||||||
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
|
model.addAttribute("q", q == null ? "" : q);
|
||||||
|
model.addAttribute("pageTitle", "服务中心 - " + site.getName());
|
||||||
|
model.addAttribute("seoDescription", null);
|
||||||
|
Map<String, Object> canonicalQuery = new LinkedHashMap<>(currentFilters);
|
||||||
|
if (serviceList.getPage() != null && serviceList.getPage() > 1) {
|
||||||
|
canonicalQuery.put("page", serviceList.getPage());
|
||||||
|
}
|
||||||
|
model.addAttribute("canonical", url.serviceList(canonicalQuery));
|
||||||
|
return "themes/" + effectiveTheme + "/service";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String doGuide(String sitePath, Integer itemId,
|
||||||
|
HttpServletRequest request, HttpServletResponse response,
|
||||||
|
Model model) throws Exception {
|
||||||
|
PortalSiteSnapshot site = requireSiteByPath(sitePath, "sv/" + itemId, request, response);
|
||||||
|
if (site == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// P1-4:svc-hall 连接类异常降级空态渲染 200;业务性缺失(code=2→null)维持 404 语义
|
||||||
|
PortalServiceItem item;
|
||||||
|
boolean svcUnavailable;
|
||||||
|
try {
|
||||||
|
item = svcItemQuery.portalItem(itemId, site.getId());
|
||||||
|
svcUnavailable = false;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[portal] 事项服务不可达(指南页降级空态渲染)siteId={} itemId={}:{}",
|
||||||
|
site.getId(), itemId, e.toString());
|
||||||
|
item = null;
|
||||||
|
svcUnavailable = true;
|
||||||
|
}
|
||||||
|
if (item == null && !svcUnavailable) {
|
||||||
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "guide.html");
|
||||||
|
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
|
||||||
|
applyServiceNavHighlight(site, url, model);
|
||||||
|
model.addAttribute("svcUnavailable", svcUnavailable);
|
||||||
|
if (svcUnavailable) {
|
||||||
|
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
|
||||||
|
return "themes/" + effectiveTheme + "/guide";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
svcItemQuery.countView(itemId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.debug("[portal] 浏览量累加失败(忽略)itemId={}:{}", itemId, e.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
model.addAttribute("url", url);
|
||||||
|
model.addAttribute("site", site);
|
||||||
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
|
model.addAttribute("themeBase", "themes/" + effectiveTheme);
|
||||||
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
|
// 富文本渲染期补基准(copy-on-write,不污染缓存体;内容已在保存期过 jsoup 白名单)
|
||||||
|
model.addAttribute("item", item.withRewrittenHtml(
|
||||||
|
url.rewriteUploadUrls(item.getProcessHtml()),
|
||||||
|
url.rewriteUploadUrls(item.getMaterialHtml()),
|
||||||
|
url.rewriteUploadUrls(item.getNoticeHtml())));
|
||||||
|
model.addAttribute("attachments", attachmentsQuietly(itemId));
|
||||||
|
List<Map<String, Object>> related = new ArrayList<>();
|
||||||
|
for (PortalServiceItem rel : relatedQuietly(site.getId(), itemId, item.getCategoryKey())) {
|
||||||
|
Map<String, Object> row = new HashMap<>();
|
||||||
|
row.put("id", rel.getId());
|
||||||
|
row.put("title", rel.getTitle());
|
||||||
|
row.put("deptName", rel.getDeptName());
|
||||||
|
row.put("handleMode", rel.getHandleMode());
|
||||||
|
row.put("url", url.serviceItem(rel.getId()));
|
||||||
|
related.add(row);
|
||||||
|
}
|
||||||
|
model.addAttribute("relatedItems", related);
|
||||||
|
if (item.getFaqCategoryId() != null) {
|
||||||
|
model.addAttribute("faqCategoryUrl", url.category(item.getFaqCategoryId()));
|
||||||
|
}
|
||||||
|
if (item.getNoticeCategoryId() != null) {
|
||||||
|
model.addAttribute("noticeCategoryUrl", url.category(item.getNoticeCategoryId()));
|
||||||
|
}
|
||||||
|
if (item.getRuleCategoryId() != null) {
|
||||||
|
model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId()));
|
||||||
|
}
|
||||||
|
// "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先)
|
||||||
|
// ② 未填且站点配了 svc_hall_base 且事项开通在线办理(onlineFlag=1 且绑流程,§3.7——
|
||||||
|
// 与 svc-hall 侧 submit 校验"未开通在线办理"口径对齐,线下/纯指南事项不再渲染按钮)
|
||||||
|
// ——深链带 from=指南页回跳地址,hall 据此显示"返回门户"按钮(T4 闭环)
|
||||||
|
// ③ 其余 → 不注入(模板不渲染按钮)
|
||||||
|
String applyUrl = item.getApplyUrl();
|
||||||
|
boolean onlineHandlable = item.getOnlineFlag() != null && item.getOnlineFlag() == 1
|
||||||
|
&& StringUtils.hasText(item.getFlowGuid());
|
||||||
|
if ((applyUrl == null || applyUrl.trim().isEmpty()) && onlineHandlable
|
||||||
|
&& site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) {
|
||||||
|
applyUrl = SvcHallLinks.build(site.getSvcHallBase(), site.getCode(),
|
||||||
|
item.getItemCode(), url.serviceItem(itemId));
|
||||||
|
}
|
||||||
|
if (applyUrl != null && !applyUrl.trim().isEmpty()) {
|
||||||
|
model.addAttribute("applyUrl", applyUrl);
|
||||||
|
}
|
||||||
|
if (item.getCoverImage() != null && !item.getCoverImage().trim().isEmpty()) {
|
||||||
|
model.addAttribute("ogImage", url.upload(item.getCoverImage()));
|
||||||
|
}
|
||||||
|
model.addAttribute("pageTitle", item.getTitle() + " - " + site.getName());
|
||||||
|
model.addAttribute("seoDescription", item.getSummary());
|
||||||
|
model.addAttribute("canonical", url.serviceItem(itemId));
|
||||||
|
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
|
||||||
|
return "themes/" + effectiveTheme + "/guide";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务中心/指南页的顶导航高亮:服务中心页不是栏目页,currentCategoryId 为空会让
|
||||||
|
* 皮肤 header 的"首页"误高亮。若站点导航里存在指向本站服务中心列表页的外链栏目
|
||||||
|
* (type=3,如"服务中心"),则把它视为当前栏目——既有高亮逻辑即同时达成
|
||||||
|
* "首页不高亮 + 服务中心高亮";找不到匹配栏目时置 navServiceActive,
|
||||||
|
* 皮肤以该标记单独关闭"首页"高亮(无服务中心入口的站点保持原状)。
|
||||||
|
*/
|
||||||
|
private void applyServiceNavHighlight(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
|
||||||
|
String servicePath = pathOf(url.serviceList());
|
||||||
|
Integer matched = null;
|
||||||
|
if (servicePath != null) {
|
||||||
|
for (PortalCategory cat : site.getCategories()) {
|
||||||
|
if (isServiceNavLink(cat, servicePath)) {
|
||||||
|
matched = cat.getId();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
for (PortalCategory sub : cat.getChildren()) {
|
||||||
|
if (isServiceNavLink(sub, servicePath)) {
|
||||||
|
matched = sub.getId();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched != null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched != null) {
|
||||||
|
model.addAttribute("currentCategoryId", matched);
|
||||||
|
} else {
|
||||||
|
model.addAttribute("navServiceActive", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isServiceNavLink(PortalCategory cat, String servicePath) {
|
||||||
|
Integer type = cat.getType();
|
||||||
|
return type != null && type == CategoryTypeEnum.LINK.getCode()
|
||||||
|
&& servicePath.equals(pathOf(cat.getLinkUrl()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 URL 的归一化路径:剥 scheme://host / query / fragment / 尾斜杠(相对链接原样返回路径) */
|
||||||
|
private static String pathOf(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String u = url.trim();
|
||||||
|
int hash = u.indexOf('#');
|
||||||
|
if (hash >= 0) {
|
||||||
|
u = u.substring(0, hash);
|
||||||
|
}
|
||||||
|
int query = u.indexOf('?');
|
||||||
|
if (query >= 0) {
|
||||||
|
u = u.substring(0, query);
|
||||||
|
}
|
||||||
|
while (u.endsWith("/")) {
|
||||||
|
u = u.substring(0, u.length() - 1);
|
||||||
|
}
|
||||||
|
int scheme = u.indexOf("://");
|
||||||
|
if (scheme >= 0) {
|
||||||
|
int slash = u.indexOf('/', scheme + 3);
|
||||||
|
u = slash >= 0 ? u.substring(slash) : "";
|
||||||
|
}
|
||||||
|
return u.isEmpty() ? null : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门户页面公共骨架属性(P1-4 降级路径与正常路径共用;q/seoDescription 等页面特有属性由调用方补) */
|
||||||
|
private void addChromeAttributes(Model model, PortalSiteSnapshot site, HttpServletRequest request,
|
||||||
|
String effectiveTheme, PortalUrlHelper url, String pageTitle) {
|
||||||
|
model.addAttribute("url", url);
|
||||||
|
model.addAttribute("site", site);
|
||||||
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
|
model.addAttribute("themeBase", "themes/" + effectiveTheme);
|
||||||
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
|
model.addAttribute("pageTitle", pageTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 附件查询静默降级(svc-hall 抖动不阻断已可渲染的指南页;业务性缺失已在查询层转空) */
|
||||||
|
private List<com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo> attachmentsQuietly(Integer itemId) {
|
||||||
|
try {
|
||||||
|
return svcItemQuery.attachments(itemId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[portal] 附件查询失败(降级为空)itemId={}:{}", itemId, e.toString());
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相关事项查询静默降级(同上) */
|
||||||
|
private List<PortalServiceItem> relatedQuietly(Integer siteId, Integer itemId, String categoryKey) {
|
||||||
|
try {
|
||||||
|
return svcItemQuery.relatedItems(siteId, itemId, categoryKey, RELATED_ITEMS_SIZE);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("[portal] 相关事项查询失败(降级为空)itemId={}:{}", itemId, e.toString());
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */
|
||||||
|
private Map<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
|
||||||
|
Map<String, Object> filters = new LinkedHashMap<>();
|
||||||
|
if (trimToNull(obj) != null) {
|
||||||
|
filters.put("obj", obj.trim());
|
||||||
|
}
|
||||||
|
if (trimToNull(cat) != null) {
|
||||||
|
filters.put("cat", cat.trim());
|
||||||
|
}
|
||||||
|
if (dept != null) {
|
||||||
|
filters.put("dept", dept);
|
||||||
|
}
|
||||||
|
if (q != null && !q.trim().isEmpty()) {
|
||||||
|
filters.put("q", q.trim());
|
||||||
|
}
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三组筛选项 → 模板 Facet({code|id, name, count, active, url},url 直用于 th:href) */
|
||||||
|
private Map<String, Object> buildServiceFilters(Integer siteId, PortalUrlHelper url,
|
||||||
|
String obj, String cat, Integer dept,
|
||||||
|
Map<String, Object> currentFilters) {
|
||||||
|
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
|
||||||
|
Map<String, Object> model = new HashMap<>();
|
||||||
|
List<PortalServiceFilters.Facet> objects = new ArrayList<>();
|
||||||
|
for (PortalServiceFilters.Option option : filters.getObjects()) {
|
||||||
|
boolean active = option.getKey().equals(trimToNull(obj));
|
||||||
|
objects.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
|
||||||
|
option.getCount(), active,
|
||||||
|
url.serviceList(toggleFilter(currentFilters, "obj", active ? null : option.getKey()))));
|
||||||
|
}
|
||||||
|
List<PortalServiceFilters.Facet> categories = new ArrayList<>();
|
||||||
|
for (PortalServiceFilters.Option option : filters.getCategories()) {
|
||||||
|
boolean active = option.getKey().equals(trimToNull(cat));
|
||||||
|
categories.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
|
||||||
|
option.getCount(), active,
|
||||||
|
url.serviceList(toggleFilter(currentFilters, "cat", active ? null : option.getKey()))));
|
||||||
|
}
|
||||||
|
List<PortalServiceFilters.Facet> depts = new ArrayList<>();
|
||||||
|
for (PortalServiceFilters.DeptOption option : filters.getDepts()) {
|
||||||
|
boolean active = option.getId() != null && option.getId().equals(dept);
|
||||||
|
depts.add(PortalServiceFilters.Facet.ofId(option.getId(), option.getName(),
|
||||||
|
option.getCount(), active,
|
||||||
|
url.serviceList(toggleFilter(currentFilters, "dept",
|
||||||
|
active ? null : String.valueOf(option.getId())))));
|
||||||
|
}
|
||||||
|
model.put("objects", objects);
|
||||||
|
model.put("categories", categories);
|
||||||
|
model.put("depts", depts);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制当前筛选并替换/移除某一维(其余维度保留,page 永不进入——选项切换即归第 1 页) */
|
||||||
|
private Map<String, Object> toggleFilter(Map<String, Object> current, String key, String value) {
|
||||||
|
Map<String, Object> next = new LinkedHashMap<>(current);
|
||||||
|
next.remove("page");
|
||||||
|
if (value == null) {
|
||||||
|
next.remove(key);
|
||||||
|
} else {
|
||||||
|
next.put(key, value);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
|
||||||
|
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
|
||||||
|
* 命中子站(path≠code)则 301 到嵌套形态(保留子路径 subPath 与 query)后返回 null;
|
||||||
|
* 仍未命中 404。
|
||||||
|
*/
|
||||||
|
private PortalSiteSnapshot requireSiteByPath(String sitePath, String subPath,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
|
||||||
|
if (site != null) {
|
||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
|
if (sitePath != null && !sitePath.contains("/")) {
|
||||||
|
CmsSite legacy = siteRepository.findFirstByCodeOrderByIdAsc(sitePath).orElse(null);
|
||||||
|
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
|
||||||
|
&& !legacy.getPath().equals(sitePath)) {
|
||||||
|
StringBuilder location = new StringBuilder(request.getContextPath())
|
||||||
|
.append("/portal/").append(legacy.getPath()).append('/');
|
||||||
|
if (subPath != null && !subPath.isEmpty()) {
|
||||||
|
location.append(subPath);
|
||||||
|
}
|
||||||
|
String query = request.getQueryString();
|
||||||
|
if (query != null && !query.isEmpty()) {
|
||||||
|
location.append('?').append(query);
|
||||||
|
}
|
||||||
|
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
|
||||||
|
response.setHeader("Location", location.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.sendError(HttpServletResponse.SC_NOT_FOUND);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL 生成器基准三级决策(方案 §8.6):
|
* URL 生成器基准三级决策(方案 §8.6):
|
||||||
* ① 站点 public_base_url(绑学校域名);
|
* ① 站点 public_base_url(绑学校域名;W4 起子站可为 D4 派生值 {主站base}/{code});
|
||||||
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
|
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
|
||||||
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
|
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
|
||||||
* theme 随 helper 走,使资产 URL 含主题段(B-3)。
|
* theme 随 helper 走,使资产 URL 含主题段(B-3)。
|
||||||
|
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
|
||||||
*/
|
*/
|
||||||
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
|
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
|
||||||
|
return buildUrl(site, request, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M0 可选模板整页回退配套(事项库 §4.2):effectiveTheme 非空时以它构造 URL 生成器——
|
||||||
|
* 资产 URL 主题段(asset())必须与视图名/themeBase 同主题,否则 default 的 HTML 配别家皮肤的 CSS。
|
||||||
|
* 三级基准决策不变(public_base_url → base-origin → 当前请求派生)。
|
||||||
|
*/
|
||||||
|
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request, String effectiveTheme) {
|
||||||
|
String theme = effectiveTheme == null ? site.getTheme() : effectiveTheme;
|
||||||
if (site.getPublicBaseUrl() != null) {
|
if (site.getPublicBaseUrl() != null) {
|
||||||
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getCode(), site.getTheme(), baseOrigin);
|
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), theme, baseOrigin);
|
||||||
}
|
}
|
||||||
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
|
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
|
||||||
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getCode(), site.getTheme());
|
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), theme);
|
||||||
}
|
}
|
||||||
return PortalUrlHelper.fromRequest(request, site.getCode(), site.getTheme());
|
return PortalUrlHelper.fromRequest(request, site.getPath(), theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
|
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
|
||||||
@@ -265,6 +780,7 @@ public class PortalController {
|
|||||||
item.setTitle(a.getTitle());
|
item.setTitle(a.getTitle());
|
||||||
item.setSummary(a.getSummary());
|
item.setSummary(a.getSummary());
|
||||||
item.setCoverImage(a.getCoverImage());
|
item.setCoverImage(a.getCoverImage());
|
||||||
|
item.setSource(a.getSource());
|
||||||
item.setPublishTime(a.getPublishTime());
|
item.setPublishTime(a.getPublishTime());
|
||||||
item.setTopmost(a.getTopmost());
|
item.setTopmost(a.getTopmost());
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -32,6 +32,6 @@ public class PortalIndexController {
|
|||||||
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
|
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(sites.get(0));
|
.orElse(sites.get(0));
|
||||||
response.sendRedirect(request.getContextPath() + "/portal/" + target.getCode() + "/");
|
response.sendRedirect(request.getContextPath() + "/portal/" + target.getPath() + "/");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import com.yunhai.cms.enums.LinkTypeEnum;
|
|||||||
import com.yunhai.cms.model.portal.PortalBanner;
|
import com.yunhai.cms.model.portal.PortalBanner;
|
||||||
import com.yunhai.cms.model.portal.PortalCategory;
|
import com.yunhai.cms.model.portal.PortalCategory;
|
||||||
import com.yunhai.cms.model.portal.PortalListData;
|
import com.yunhai.cms.model.portal.PortalListData;
|
||||||
|
import com.yunhai.cms.model.portal.PortalSideNav;
|
||||||
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
|
||||||
import com.yunhai.cms.repository.CmsArticleRepository;
|
import com.yunhai.cms.repository.CmsArticleRepository;
|
||||||
|
import com.yunhai.cms.theme.ThemeRegistry;
|
||||||
import com.yunhai.cms.utils.PortalUrlHelper;
|
import com.yunhai.cms.utils.PortalUrlHelper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -19,23 +22,49 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
|
||||||
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
|
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
|
||||||
*/
|
*/
|
||||||
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PortalPageRenderer {
|
public class PortalPageRenderer {
|
||||||
|
|
||||||
/** 首页每个一级栏目取 N 条 */
|
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
|
||||||
private static final int HOME_BLOCK_SIZE = 8;
|
private static final int HOME_BLOCK_SIZE = 8;
|
||||||
|
private static final int HOME_BLOCK_GRID_SIZE = 16;
|
||||||
/** 首页置顶区取 N 条 */
|
/** 首页置顶区取 N 条 */
|
||||||
private static final int HOME_TOP_SIZE = 6;
|
private static final int HOME_TOP_SIZE = 6;
|
||||||
|
|
||||||
private final CmsArticleRepository articleRepository;
|
private final CmsArticleRepository articleRepository;
|
||||||
|
private final ThemeRegistry themeRegistry;
|
||||||
|
/** docs/20 D-7:people 区块数据源 */
|
||||||
|
private final com.yunhai.cms.repository.CmsPersonRepository personRepository;
|
||||||
|
|
||||||
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */
|
/**
|
||||||
|
* M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html
|
||||||
|
* 这类 REQUIRED_TEMPLATES 之外的可选页)时,整页回退 default,而非仅换模板。
|
||||||
|
* 返回的 effectiveTheme 必须同时用于视图名、themeBase 与 URL 生成器(asset() 的
|
||||||
|
* 资产主题段)——只换模板不换主题 = default 的 HTML 配别家皮肤的 CSS。
|
||||||
|
* 必备四页由 ThemePackageValidator 注册期保证,不走本判定。
|
||||||
|
*/
|
||||||
|
public String resolveEffectiveTheme(PortalSiteSnapshot site, String requiredTemplate) {
|
||||||
|
String theme = site.getTheme();
|
||||||
|
if (themeRegistry.resource(theme, requiredTemplate).exists()) {
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
log.warn("主题 {} 缺可选模板 {}(site={}),整页回退 {}", theme, requiredTemplate,
|
||||||
|
site.getCode(), ThemeRegistry.DEFAULT_THEME);
|
||||||
|
return ThemeRegistry.DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
|
||||||
|
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
|
||||||
|
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
|
||||||
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
|
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
|
||||||
model.addAttribute("url", url);
|
model.addAttribute("url", url);
|
||||||
model.addAttribute("site", site);
|
model.addAttribute("site", site);
|
||||||
@@ -46,9 +75,20 @@ public class PortalPageRenderer {
|
|||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
||||||
model.addAttribute("banners", banners);
|
model.addAttribute("banners", banners);
|
||||||
model.addAttribute("topArticles", topArticles(site.getId()));
|
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
|
||||||
|
if (layoutBlocks != null) {
|
||||||
|
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
|
||||||
|
model.addAttribute("layoutBlocks", layoutBlocks);
|
||||||
|
model.addAttribute("galleryBanners", new ArrayList<>());
|
||||||
|
model.addAttribute("topArticles", new ArrayList<>());
|
||||||
|
model.addAttribute("focusArticles", new ArrayList<>());
|
||||||
|
model.addAttribute("categoryBlocks", new ArrayList<>());
|
||||||
|
} else {
|
||||||
|
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
|
||||||
|
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
|
||||||
model.addAttribute("focusArticles", focusArticles(site.getId()));
|
model.addAttribute("focusArticles", focusArticles(site.getId()));
|
||||||
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
|
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
|
||||||
|
}
|
||||||
model.addAttribute("pageTitle", site.getName());
|
model.addAttribute("pageTitle", site.getName());
|
||||||
model.addAttribute("seoDescription", site.getConfig() == null ? null
|
model.addAttribute("seoDescription", site.getConfig() == null ? null
|
||||||
: site.getConfig().get("seo_description"));
|
: site.getConfig().get("seo_description"));
|
||||||
@@ -63,6 +103,71 @@ public class PortalPageRenderer {
|
|||||||
|
|
||||||
// ===== 内部装配(自 PortalController 原样迁移)=====
|
// ===== 内部装配(自 PortalController 原样迁移)=====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
|
||||||
|
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
|
||||||
|
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
|
||||||
|
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
|
||||||
|
*/
|
||||||
|
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
|
||||||
|
if (currentCategoryId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
|
||||||
|
model.addAttribute("categoryPath", path);
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
|
||||||
|
}
|
||||||
|
PortalCategory top = path.get(0);
|
||||||
|
model.addAttribute("navTopId", top.getId());
|
||||||
|
if (!top.getChildren().isEmpty()) {
|
||||||
|
PortalSideNav nav = new PortalSideNav();
|
||||||
|
nav.setTop(top);
|
||||||
|
nav.setItems(top.getChildren());
|
||||||
|
nav.setActiveId(resolveActiveId(path, currentCategoryId));
|
||||||
|
model.addAttribute("sideNav", nav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
|
||||||
|
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
|
||||||
|
*/
|
||||||
|
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
|
||||||
|
if (path.size() == 2) {
|
||||||
|
return currentCategoryId;
|
||||||
|
}
|
||||||
|
return path.size() > 2 ? path.get(1).getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
|
||||||
|
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
|
||||||
|
for (PortalCategory root : roots) {
|
||||||
|
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
|
||||||
|
if (hit != null) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
|
||||||
|
if (node.getId().equals(categoryId)) {
|
||||||
|
List<PortalCategory> path = new ArrayList<>();
|
||||||
|
path.add(node);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
for (PortalCategory child : node.getChildren()) {
|
||||||
|
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
|
||||||
|
if (sub != null) {
|
||||||
|
sub.add(0, node);
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
|
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
|
||||||
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
|
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
|
||||||
PortalUrlHelper url) {
|
PortalUrlHelper url) {
|
||||||
@@ -101,10 +206,10 @@ public class PortalPageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
|
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
|
||||||
private List<PortalListData.Item> topArticles(Integer siteId) {
|
private List<PortalListData.Item> topArticles(Integer siteId, int size) {
|
||||||
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
|
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
|
||||||
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
||||||
PageRequest.of(0, HOME_TOP_SIZE,
|
PageRequest.of(0, size,
|
||||||
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
|
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
|
||||||
List<PortalListData.Item> items = new ArrayList<>();
|
List<PortalListData.Item> items = new ArrayList<>();
|
||||||
for (com.yunhai.cms.entity.CmsArticle a : articles) {
|
for (com.yunhai.cms.entity.CmsArticle a : articles) {
|
||||||
@@ -137,6 +242,7 @@ public class PortalPageRenderer {
|
|||||||
/**
|
/**
|
||||||
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
|
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
|
||||||
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
|
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
|
||||||
|
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
|
||||||
*/
|
*/
|
||||||
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
|
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
|
||||||
PortalUrlHelper url) {
|
PortalUrlHelper url) {
|
||||||
@@ -145,12 +251,14 @@ public class PortalPageRenderer {
|
|||||||
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
|
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
int blockSize = "news_grid".equals(category.getHomeStyle())
|
||||||
|
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
|
||||||
List<Integer> categoryIds = new ArrayList<>();
|
List<Integer> categoryIds = new ArrayList<>();
|
||||||
collectListDescendantIds(category, categoryIds);
|
collectListDescendantIds(category, categoryIds);
|
||||||
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
|
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
|
||||||
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
|
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
|
||||||
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
||||||
PageRequest.of(0, HOME_BLOCK_SIZE,
|
PageRequest.of(0, blockSize,
|
||||||
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
|
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
|
||||||
.getContent();
|
.getContent();
|
||||||
List<PortalListData.Item> items = new ArrayList<>();
|
List<PortalListData.Item> items = new ArrayList<>();
|
||||||
@@ -159,6 +267,7 @@ public class PortalPageRenderer {
|
|||||||
}
|
}
|
||||||
Map<String, Object> block = new HashMap<>();
|
Map<String, Object> block = new HashMap<>();
|
||||||
block.put("category", category);
|
block.put("category", category);
|
||||||
|
block.put("homeStyle", category.getHomeStyle());
|
||||||
block.put("items", items);
|
block.put("items", items);
|
||||||
block.put("moreUrl", url.category(category.getId()));
|
block.put("moreUrl", url.category(category.getId()));
|
||||||
blocks.add(block);
|
blocks.add(block);
|
||||||
@@ -176,12 +285,542 @@ public class PortalPageRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 首页布局装配(docs/16 主线B §4.4)=====
|
||||||
|
|
||||||
|
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
|
||||||
|
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)。
|
||||||
|
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
|
||||||
|
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
|
||||||
|
PortalUrlHelper url) {
|
||||||
|
if (homeLayout == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object raw = homeLayout.get("blocks");
|
||||||
|
if (!(raw instanceof List)) {
|
||||||
|
return null; // 容错:blocks 非数组视为无布局
|
||||||
|
}
|
||||||
|
Set<Integer> treeIds = new java.util.HashSet<>();
|
||||||
|
collectTreeIds(site.getCategories(), treeIds);
|
||||||
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
|
for (Object o : (List<?>) raw) {
|
||||||
|
if (!(o instanceof Map)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> spec = (Map<String, Object>) o;
|
||||||
|
if (Boolean.FALSE.equals(spec.get("visible"))) {
|
||||||
|
continue; // 编辑器「隐藏」:保序下线
|
||||||
|
}
|
||||||
|
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
|
||||||
|
if (block != null) {
|
||||||
|
result.add(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
|
||||||
|
for (PortalCategory c : categories) {
|
||||||
|
ids.add(c.getId());
|
||||||
|
collectTreeIds(c.getChildren(), ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
|
||||||
|
Set<Integer> treeIds, PortalUrlHelper url) {
|
||||||
|
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
|
||||||
|
switch (type) {
|
||||||
|
case "article_list":
|
||||||
|
return buildArticleListBlock(site, spec, treeIds, url);
|
||||||
|
case "ticker":
|
||||||
|
return buildTickerBlock(site, spec, treeIds);
|
||||||
|
case "gallery":
|
||||||
|
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 取标题)。
|
||||||
|
* 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);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
|
||||||
|
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
|
||||||
|
Set<Integer> treeIds, PortalUrlHelper url) {
|
||||||
|
List<Integer> categoryIds = new ArrayList<>();
|
||||||
|
PortalCategory first = null;
|
||||||
|
for (Integer id : intList(spec.get("categoryIds"))) {
|
||||||
|
if (!treeIds.contains(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PortalCategory node = findInTree(site.getCategories(), id);
|
||||||
|
if (node == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
collectListDescendantIds(node, categoryIds);
|
||||||
|
if (first == null) {
|
||||||
|
first = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
|
||||||
|
List<PortalListData.Item> items = categoryIds.isEmpty()
|
||||||
|
? new ArrayList<>()
|
||||||
|
: articlesIn(site.getId(), categoryIds, size);
|
||||||
|
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
|
||||||
|
Map<String, Object> block = baseBlock(spec, "article_list",
|
||||||
|
first != null ? first.getName() : "栏目文章");
|
||||||
|
block.put("style", style);
|
||||||
|
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
|
||||||
|
block.put("items", items);
|
||||||
|
block.put("size", size);
|
||||||
|
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
|
||||||
|
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
|
||||||
|
boolean background = spec.get("background") != null
|
||||||
|
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
|
||||||
|
: hasText(configValue(site, "news_bg_image"));
|
||||||
|
block.put("background", background);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
|
||||||
|
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
|
||||||
|
Set<Integer> treeIds) {
|
||||||
|
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
|
||||||
|
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
|
||||||
|
List<PortalListData.Item> items;
|
||||||
|
if ("category".equals(source)) {
|
||||||
|
List<Integer> categoryIds = new ArrayList<>();
|
||||||
|
for (Integer id : intList(spec.get("categoryIds"))) {
|
||||||
|
if (!treeIds.contains(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
PortalCategory node = findInTree(site.getCategories(), id);
|
||||||
|
if (node != null) {
|
||||||
|
collectListDescendantIds(node, categoryIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
|
||||||
|
} else {
|
||||||
|
items = topArticles(site.getId(), size);
|
||||||
|
}
|
||||||
|
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
|
||||||
|
block.put("source", source);
|
||||||
|
block.put("items", items);
|
||||||
|
block.put("size", size);
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
|
||||||
|
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
|
||||||
|
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
|
||||||
|
PortalUrlHelper url) {
|
||||||
|
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
|
||||||
|
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
|
||||||
|
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
|
||||||
|
if (subtitle.isEmpty()) {
|
||||||
|
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
|
||||||
|
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
|
||||||
|
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
|
||||||
|
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
|
||||||
|
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
|
||||||
|
if (group.isEmpty() || group.equals(g.getGroupName())) {
|
||||||
|
groups.add(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
|
||||||
|
block.put("group", group);
|
||||||
|
block.put("linkGroups", groups);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasText(String v) {
|
||||||
|
return v != null && !v.trim().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String configValue(PortalSiteSnapshot site, String key) {
|
||||||
|
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
|
||||||
|
return v == null ? null : String.valueOf(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Integer> intList(Object v) {
|
||||||
|
List<Integer> ids = new ArrayList<>();
|
||||||
|
if (v instanceof List) {
|
||||||
|
for (Object o : (List<?>) v) {
|
||||||
|
if (o instanceof Number && ((Number) o).intValue() > 0) {
|
||||||
|
ids.add(((Number) o).intValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clampSize(Object v, int fallback) {
|
||||||
|
if (!(v instanceof Number)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
int n = ((Number) v).intValue();
|
||||||
|
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
|
||||||
|
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
|
||||||
|
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
|
||||||
|
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
|
||||||
|
PageRequest.of(0, size,
|
||||||
|
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
|
||||||
|
.getContent();
|
||||||
|
List<PortalListData.Item> items = new ArrayList<>();
|
||||||
|
for (com.yunhai.cms.entity.CmsArticle a : articles) {
|
||||||
|
items.add(toItem(a));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
|
||||||
|
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
|
||||||
|
for (PortalCategory c : categories) {
|
||||||
|
if (c.getId().equals(id)) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
PortalCategory hit = findInTree(c.getChildren(), id);
|
||||||
|
if (hit != null) {
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
|
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
|
||||||
PortalListData.Item item = new PortalListData.Item();
|
PortalListData.Item item = new PortalListData.Item();
|
||||||
item.setId(a.getId());
|
item.setId(a.getId());
|
||||||
item.setTitle(a.getTitle());
|
item.setTitle(a.getTitle());
|
||||||
item.setSummary(a.getSummary());
|
item.setSummary(a.getSummary());
|
||||||
item.setCoverImage(a.getCoverImage());
|
item.setCoverImage(a.getCoverImage());
|
||||||
|
item.setSource(a.getSource());
|
||||||
item.setPublishTime(a.getPublishTime());
|
item.setPublishTime(a.getPublishTime());
|
||||||
item.setTopmost(a.getTopmost());
|
item.setTopmost(a.getTopmost());
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -59,4 +59,27 @@ public class CmsBanner extends BaseEntity {
|
|||||||
/** 状态:0=停用 1=启用 */
|
/** 状态:0=停用 1=启用 */
|
||||||
@Column(name = "status", nullable = false)
|
@Column(name = "status", nullable = false)
|
||||||
private Integer status = 1;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class CmsCategory extends BaseEntity {
|
|||||||
@Column(name = "template", length = 64)
|
@Column(name = "template", length = 64)
|
||||||
private String template;
|
private String template;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页展示形态(docs/11,空=主题默认卡):news_grid=双栏新闻卡、media_focus=来源标签列表。
|
||||||
|
* 仅一级列表型栏目在首页生效;旧主题不消费该字段(仅条数外溢 16)。
|
||||||
|
*/
|
||||||
|
@Column(name = "home_style", length = 32)
|
||||||
|
private String homeStyle;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
|
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
|
||||||
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
|
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -24,14 +24,24 @@ import javax.persistence.UniqueConstraint;
|
|||||||
@Setter
|
@Setter
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "cms_site",
|
@Table(name = "cms_site",
|
||||||
uniqueConstraints = @UniqueConstraint(name = "uk_site_code", columnNames = "code"),
|
uniqueConstraints = {
|
||||||
|
@UniqueConstraint(name = "uk_site_path", columnNames = "path"),
|
||||||
|
@UniqueConstraint(name = "uk_site_parent_code", columnNames = {"parent_site_id", "code"})
|
||||||
|
},
|
||||||
indexes = @Index(name = "idx_site_sch", columnList = "sch_id"))
|
indexes = @Index(name = "idx_site_sch", columnList = "sch_id"))
|
||||||
public class CmsSite extends BaseEntity {
|
public class CmsSite extends BaseEntity {
|
||||||
|
|
||||||
/** 站点编码,URL 路径用(/portal/{code}) */
|
/** 站点编码(URL 末段;W4 起不再全局唯一——不同学校可重用同一编码) */
|
||||||
@Column(name = "code", nullable = false, length = 64)
|
@Column(name = "code", nullable = false, length = 64)
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户路径(W4 嵌套路径,方案 §8.4/D2):顶层=code;子站=父path+'/'+code。
|
||||||
|
* 门户 URL 唯一标识(/portal/{path}/**),全站唯一。
|
||||||
|
*/
|
||||||
|
@Column(name = "path", nullable = false, length = 191)
|
||||||
|
private String path = "";
|
||||||
|
|
||||||
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */
|
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */
|
||||||
@Column(name = "parent_site_id", nullable = false)
|
@Column(name = "parent_site_id", nullable = false)
|
||||||
private Integer parentSiteId = 0;
|
private Integer parentSiteId = 0;
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ public class PortalCacheEvictListener {
|
|||||||
clear(CacheNames.LIST);
|
clear(CacheNames.LIST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 事项域缓存(cms:svcItem):V2.0 拆分后无本地写事件,remote 模式靠 TTL 过期
|
||||||
|
// (办事大厅变更后最多滞后 5min);ServiceItemChangedEvent 已随事项管理能力迁出。
|
||||||
|
|
||||||
private void evictArticle(Integer articleId) {
|
private void evictArticle(Integer articleId) {
|
||||||
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
|
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
|
||||||
if (cache != null && articleId != null) {
|
if (cache != null && articleId != null) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import javax.validation.constraints.Size;
|
|||||||
import java.time.LocalDateTime;
|
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
|
@Data
|
||||||
public class BannerSaveRequest {
|
public class BannerSaveRequest {
|
||||||
@@ -49,4 +49,25 @@ public class BannerSaveRequest {
|
|||||||
|
|
||||||
/** 0=停用 1=启用 */
|
/** 0=停用 1=启用 */
|
||||||
private Integer status = 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ public class CategorySaveRequest {
|
|||||||
@Size(max = 64)
|
@Size(max = 64)
|
||||||
private String template;
|
private String template;
|
||||||
|
|
||||||
|
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus,服务端白名单校验) */
|
||||||
|
@Size(max = 32)
|
||||||
|
private String homeStyle;
|
||||||
|
|
||||||
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
|
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
private String configJson;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key;
|
* 主题微调参数(方案 §5.3)。
|
||||||
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。
|
*
|
||||||
|
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
|
||||||
|
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
|
||||||
*/
|
*/
|
||||||
|
@Deprecated
|
||||||
private Map<String, String> themeParams;
|
private Map<String, String> themeParams;
|
||||||
|
|
||||||
/** 0=停用 1=启用 */
|
/** 0=停用 1=启用 */
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.yunhai.cms.model.dto;
|
||||||
|
|
||||||
|
import com.yunhai.cms.theme.ThemeParam;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** W1 皮肤包派生请求(POST /api/theme/derive);任务四增选填 title/description(写入新包 theme.json) */
|
||||||
|
@Data
|
||||||
|
public class ThemeDeriveRequest {
|
||||||
|
|
||||||
|
/** 派生源包名(内置或外部) */
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
/** 新包名(外部包目录名,theme.json name 同步改写) */
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** 显示名称(选填;留空沿用源包 title) */
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** 描述(选填;null=沿用源包,""=清空) */
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.yunhai.cms.model.dto;
|
||||||
|
|
||||||
|
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
|
||||||
|
public class ThemeMetaRequest {
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** 描述(null=不改;""=清空) */
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/** 参数 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;
|
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) {
|
public static PortalBanner of(CmsBanner b) {
|
||||||
PortalBanner item = new PortalBanner();
|
PortalBanner item = new PortalBanner();
|
||||||
item.setId(b.getId());
|
item.setId(b.getId());
|
||||||
@@ -45,6 +61,11 @@ public class PortalBanner {
|
|||||||
item.setSortNo(b.getSortNo());
|
item.setSortNo(b.getSortNo());
|
||||||
item.setStartTime(b.getStartTime());
|
item.setStartTime(b.getStartTime());
|
||||||
item.setEndTime(b.getEndTime());
|
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;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class PortalCategory {
|
|||||||
|
|
||||||
private String template;
|
private String template;
|
||||||
|
|
||||||
|
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
|
||||||
|
private String homeStyle;
|
||||||
|
|
||||||
private String seoTitle;
|
private String seoTitle;
|
||||||
|
|
||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
@@ -42,6 +45,7 @@ public class PortalCategory {
|
|||||||
node.setType(c.getType());
|
node.setType(c.getType());
|
||||||
node.setLinkUrl(c.getLinkUrl());
|
node.setLinkUrl(c.getLinkUrl());
|
||||||
node.setTemplate(c.getTemplate());
|
node.setTemplate(c.getTemplate());
|
||||||
|
node.setHomeStyle(c.getHomeStyle());
|
||||||
node.setSeoTitle(c.getSeoTitle());
|
node.setSeoTitle(c.getSeoTitle());
|
||||||
node.setSeoDescription(c.getSeoDescription());
|
node.setSeoDescription(c.getSeoDescription());
|
||||||
node.setContent(c.getContent());
|
node.setContent(c.getContent());
|
||||||
@@ -60,6 +64,7 @@ public class PortalCategory {
|
|||||||
copy.type = type;
|
copy.type = type;
|
||||||
copy.linkUrl = linkUrl;
|
copy.linkUrl = linkUrl;
|
||||||
copy.template = template;
|
copy.template = template;
|
||||||
|
copy.homeStyle = homeStyle;
|
||||||
copy.seoTitle = seoTitle;
|
copy.seoTitle = seoTitle;
|
||||||
copy.seoDescription = seoDescription;
|
copy.seoDescription = seoDescription;
|
||||||
copy.content = content;
|
copy.content = content;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class PortalListData {
|
|||||||
|
|
||||||
private String coverImage;
|
private String coverImage;
|
||||||
|
|
||||||
|
/** 来源(转载出处,docs/11:媒体聚焦来源徽标用;可空) */
|
||||||
|
private String source;
|
||||||
|
|
||||||
private LocalDateTime publishTime;
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
private Integer topmost;
|
private Integer topmost;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.yunhai.cms.model.portal;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户左侧栏目导航(docs/11):模块存在二级子栏目时,栏目页/详情页左侧
|
||||||
|
* 展示"模块名 + 全部二级子栏目",当前位高亮。纯展示模型,由站点快照树推导。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalSideNav {
|
||||||
|
|
||||||
|
/** 顶级模块(侧栏头) */
|
||||||
|
private PortalCategory top;
|
||||||
|
|
||||||
|
/** 全部二级子栏目(快照序,列表/单页/外链型均列出) */
|
||||||
|
private List<PortalCategory> items = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 当前高亮栏目 id:当前为二级时=当前栏目;三级及更深时=所在二级分支;一级直点=null */
|
||||||
|
private Integer activeId;
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ public class PortalSiteBrief {
|
|||||||
|
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
/** 门户路径(W4:嵌套 path,下拉链接用) */
|
||||||
|
private String path;
|
||||||
|
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
/** 站点 logo(config_json.logo,下拉项可选展示) */
|
/** 站点 logo(config_json.logo,下拉项可选展示) */
|
||||||
@@ -30,6 +33,7 @@ public class PortalSiteBrief {
|
|||||||
PortalSiteBrief brief = new PortalSiteBrief();
|
PortalSiteBrief brief = new PortalSiteBrief();
|
||||||
brief.setId(site.getId());
|
brief.setId(site.getId());
|
||||||
brief.setCode(site.getCode());
|
brief.setCode(site.getCode());
|
||||||
|
brief.setPath(site.getPath());
|
||||||
brief.setName(site.getName());
|
brief.setName(site.getName());
|
||||||
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
|
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
|
||||||
brief.setLogoUrl(ExtJsonUtils.getString(config, "logo"));
|
brief.setLogoUrl(ExtJsonUtils.getString(config, "logo"));
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ public class PortalSiteSnapshot {
|
|||||||
|
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
/** 门户路径(W4:顶层=code;子站=父path/code) */
|
||||||
|
private String path;
|
||||||
|
|
||||||
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
|
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
|
||||||
private Integer parentSiteId;
|
private Integer parentSiteId;
|
||||||
|
|
||||||
@@ -39,6 +42,12 @@ public class PortalSiteSnapshot {
|
|||||||
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
|
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
|
||||||
private String publicBaseUrl;
|
private String publicBaseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务大厅基地址(config_json.svc_hall_base,事项库 §3.7):非空时指南页"我要办理"
|
||||||
|
* 按其生成 svhall 发起页 URL(优先级低于事项手填 ext_json.apply_url);空=不生成。
|
||||||
|
*/
|
||||||
|
private String svcHallBase;
|
||||||
|
|
||||||
/** 启用栏目树(status=1,已组树) */
|
/** 启用栏目树(status=1,已组树) */
|
||||||
private List<PortalCategory> categories = new ArrayList<>();
|
private List<PortalCategory> categories = new ArrayList<>();
|
||||||
|
|
||||||
@@ -60,10 +69,24 @@ public class PortalSiteSnapshot {
|
|||||||
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
|
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
|
||||||
private String customCss = "";
|
private String customCss = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页布局(docs/16 主线B:config_json.home_layout,{version, blocks:[…]})。
|
||||||
|
* null = 未编排布局(渲染端回落「全部栏目自动渲染」现状,老站零迁移);
|
||||||
|
* 解析容错:键缺失/非法结构一律 null,绝不让脏布局打挂门户。
|
||||||
|
*/
|
||||||
|
private Map<String, Object> homeLayout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
|
||||||
|
* 规避模板内拆分的表达式平台差异)。
|
||||||
|
*/
|
||||||
|
private List<String> footerAddressLines = new ArrayList<>();
|
||||||
|
|
||||||
public static PortalSiteSnapshot of(CmsSite site) {
|
public static PortalSiteSnapshot of(CmsSite site) {
|
||||||
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
|
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
|
||||||
snapshot.setId(site.getId());
|
snapshot.setId(site.getId());
|
||||||
snapshot.setCode(site.getCode());
|
snapshot.setCode(site.getCode());
|
||||||
|
snapshot.setPath(site.getPath());
|
||||||
snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
|
snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
|
||||||
snapshot.setName(site.getName());
|
snapshot.setName(site.getName());
|
||||||
snapshot.setSchName(site.getSchName());
|
snapshot.setSchName(site.getSchName());
|
||||||
@@ -73,10 +96,37 @@ public class PortalSiteSnapshot {
|
|||||||
snapshot.setConfig(config);
|
snapshot.setConfig(config);
|
||||||
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
||||||
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
||||||
|
snapshot.setSvcHallBase(ExtJsonUtils.getString(config, "svc_hall_base"));
|
||||||
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
||||||
|
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
|
||||||
|
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** home_layout 容错归一:对象且 blocks 为数组才生效,否则视为无布局(回落态) */
|
||||||
|
private static Map<String, Object> normalizeHomeLayout(Object raw) {
|
||||||
|
if (!(raw instanceof Map)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object blocks = ((Map<?, ?>) raw).get("blocks");
|
||||||
|
return blocks instanceof List ? (Map<String, Object>) raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
|
||||||
|
private static List<String> splitAddressLines(String raw) {
|
||||||
|
List<String> lines = new ArrayList<>();
|
||||||
|
if (raw == null || raw.trim().isEmpty()) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
for (String line : raw.split("\r?\n")) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
lines.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
|
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
|
||||||
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
|
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
|
||||||
@@ -85,6 +135,7 @@ public class PortalSiteSnapshot {
|
|||||||
PortalSiteSnapshot copy = new PortalSiteSnapshot();
|
PortalSiteSnapshot copy = new PortalSiteSnapshot();
|
||||||
copy.setId(id);
|
copy.setId(id);
|
||||||
copy.setCode(code);
|
copy.setCode(code);
|
||||||
|
copy.setPath(path);
|
||||||
copy.setParentSiteId(parentSiteId);
|
copy.setParentSiteId(parentSiteId);
|
||||||
copy.setName(name);
|
copy.setName(name);
|
||||||
copy.setSchName(schName);
|
copy.setSchName(schName);
|
||||||
@@ -92,6 +143,7 @@ public class PortalSiteSnapshot {
|
|||||||
copy.setConfig(config);
|
copy.setConfig(config);
|
||||||
copy.setDomain(domain);
|
copy.setDomain(domain);
|
||||||
copy.setPublicBaseUrl(publicBaseUrl);
|
copy.setPublicBaseUrl(publicBaseUrl);
|
||||||
|
copy.setSvcHallBase(svcHallBase);
|
||||||
copy.setCategories(categories);
|
copy.setCategories(categories);
|
||||||
copy.setBanners(banners);
|
copy.setBanners(banners);
|
||||||
copy.setLinkGroups(linkGroups);
|
copy.setLinkGroups(linkGroups);
|
||||||
@@ -99,6 +151,8 @@ public class PortalSiteSnapshot {
|
|||||||
copy.setThemeParams(themeParams);
|
copy.setThemeParams(themeParams);
|
||||||
copy.setThemeVarsCss(themeVarsCss);
|
copy.setThemeVarsCss(themeVarsCss);
|
||||||
copy.setCustomCss(customCss);
|
copy.setCustomCss(customCss);
|
||||||
|
copy.setHomeLayout(homeLayout);
|
||||||
|
copy.setFooterAddressLines(footerAddressLines);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ public class BannerVo {
|
|||||||
|
|
||||||
private Integer status;
|
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) {
|
public static BannerVo of(CmsBanner b) {
|
||||||
BannerVo vo = new BannerVo();
|
BannerVo vo = new BannerVo();
|
||||||
vo.setId(b.getId());
|
vo.setId(b.getId());
|
||||||
@@ -47,6 +54,11 @@ public class BannerVo {
|
|||||||
vo.setStartTime(b.getStartTime());
|
vo.setStartTime(b.getStartTime());
|
||||||
vo.setEndTime(b.getEndTime());
|
vo.setEndTime(b.getEndTime());
|
||||||
vo.setStatus(b.getStatus());
|
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;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ public class CategoryTreeNode {
|
|||||||
|
|
||||||
private String template;
|
private String template;
|
||||||
|
|
||||||
|
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
|
||||||
|
private String homeStyle;
|
||||||
|
|
||||||
/** 单页栏目内容(type=2 时编辑/渲染用) */
|
/** 单页栏目内容(type=2 时编辑/渲染用) */
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@@ -50,6 +53,7 @@ public class CategoryTreeNode {
|
|||||||
node.setType(c.getType());
|
node.setType(c.getType());
|
||||||
node.setLinkUrl(c.getLinkUrl());
|
node.setLinkUrl(c.getLinkUrl());
|
||||||
node.setTemplate(c.getTemplate());
|
node.setTemplate(c.getTemplate());
|
||||||
|
node.setHomeStyle(c.getHomeStyle());
|
||||||
node.setContent(c.getContent());
|
node.setContent(c.getContent());
|
||||||
node.setSortNo(c.getSortNo());
|
node.setSortNo(c.getSortNo());
|
||||||
node.setSeoTitle(c.getSeoTitle());
|
node.setSeoTitle(c.getSeoTitle());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ public class SchoolVo {
|
|||||||
private Integer mainSiteId;
|
private Integer mainSiteId;
|
||||||
/** 主站编码(学校上下文 URL 段 /sch/{code}) */
|
/** 主站编码(学校上下文 URL 段 /sch/{code}) */
|
||||||
private String mainSiteCode;
|
private String mainSiteCode;
|
||||||
|
/** 主站门户路径(W4:/portal/{mainSitePath}/,顶层=code) */
|
||||||
|
private String mainSitePath;
|
||||||
private String mainSiteName;
|
private String mainSiteName;
|
||||||
private String theme;
|
private String theme;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public class SiteVo {
|
|||||||
|
|
||||||
private String code;
|
private String code;
|
||||||
|
|
||||||
|
/** 门户路径(W4:顶层=code;子站=父path/code,URL 唯一标识) */
|
||||||
|
private String path;
|
||||||
|
|
||||||
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
|
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
|
||||||
private Integer parentSiteId;
|
private Integer parentSiteId;
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ public class SiteVo {
|
|||||||
SiteVo vo = new SiteVo();
|
SiteVo vo = new SiteVo();
|
||||||
vo.setId(site.getId());
|
vo.setId(site.getId());
|
||||||
vo.setCode(site.getCode());
|
vo.setCode(site.getCode());
|
||||||
|
vo.setPath(site.getPath());
|
||||||
vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
|
vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
|
||||||
vo.setSchId(site.getSchId());
|
vo.setSchId(site.getSchId());
|
||||||
vo.setSchName(site.getSchName());
|
vo.setSchName(site.getSchName());
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.yunhai.cms.theme.ThemeParam;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题包 VO(管理端主题列表,方案 §5.2)。
|
* 主题包 VO(管理端主题列表,方案 §5.2)。
|
||||||
@@ -30,6 +31,24 @@ public class ThemeVo {
|
|||||||
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
||||||
private List<ThemeParamVo> params;
|
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;
|
||||||
|
|
||||||
|
/** 被用站点数(W1,G1:删除保护口径一致) */
|
||||||
|
private long inUseCount;
|
||||||
|
|
||||||
|
/** 是否可编辑/删除(W1:仅外部包 true) */
|
||||||
|
private boolean editable;
|
||||||
|
|
||||||
|
/** 引用站点名列表(W1:删除保护提示/前端 tooltip,与 inUseCount 同源) */
|
||||||
|
private List<String> usedBySiteNames;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class ThemeParamVo {
|
public static class ThemeParamVo {
|
||||||
|
|
||||||
@@ -70,6 +89,8 @@ public class ThemeVo {
|
|||||||
for (ThemeParam p : info.getParams()) {
|
for (ThemeParam p : info.getParams()) {
|
||||||
vo.getParams().add(ThemeParamVo.of(p));
|
vo.getParams().add(ThemeParamVo.of(p));
|
||||||
}
|
}
|
||||||
|
vo.setParamValues(info.getParamValues() == null
|
||||||
|
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
|
||||||
return vo;
|
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);
|
||||||
|
}
|
||||||
@@ -8,8 +8,38 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
|
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按站点编码取站点(严格版:命中多条时抛 IncorrectResultSizeDataAccessException)。
|
||||||
|
*
|
||||||
|
* <p>语义说明:{@code code} 自 W4 起不是全局唯一(唯一键为 {@code uk_site_parent_code(parent_site_id, code)}),
|
||||||
|
* 多校部署下不同父站可挂同名 code 子站。**生产代码请用**
|
||||||
|
* {@link #findFirstByCodeOrderByIdAsc(String)}(确定性取最早一条,不会因歧义 500);
|
||||||
|
* 本方法语义为"断言 code 唯一",保留给集成测试做唯一性/落库断言。
|
||||||
|
*/
|
||||||
Optional<CmsSite> findByCode(String code);
|
Optional<CmsSite> findByCode(String code);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按站点编码取站点(取 id 最小者)——生产路径口径。
|
||||||
|
*
|
||||||
|
* <p>歧义时不再抛异常导致 500,而是确定性取最早创建的站点;跨服务单键定位的正解仍是
|
||||||
|
* (siteCode,itemCode) 成对 + 建站保证 code 全局唯一(设计 §1.3/R4)。
|
||||||
|
*/
|
||||||
|
Optional<CmsSite> findFirstByCodeOrderByIdAsc(String code);
|
||||||
|
|
||||||
|
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
|
||||||
|
Optional<CmsSite> findByPath(String path);
|
||||||
|
|
||||||
|
/** 路径冲突检查(新建/改挂重算用;编辑排除自身) */
|
||||||
|
boolean existsByPath(String path);
|
||||||
|
|
||||||
|
boolean existsByPathAndIdNot(String path, Integer id);
|
||||||
|
|
||||||
|
/** 皮肤包被用站点数(W1 删除保护共用) */
|
||||||
|
long countByTheme(String theme);
|
||||||
|
|
||||||
|
/** 使用指定皮肤包的站点(W1 删除保护:拒绝消息提示站点名) */
|
||||||
|
List<CmsSite> findByTheme(String theme);
|
||||||
|
|
||||||
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
|
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
|
||||||
List<CmsSite> findByStatusOrderByIdAsc(Integer status);
|
List<CmsSite> findByStatusOrderByIdAsc(Integer status);
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,17 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
|
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
|
||||||
@@ -46,6 +52,10 @@ public class AttachmentService {
|
|||||||
|
|
||||||
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
|
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
|
||||||
|
|
||||||
|
/** 富文本资产地址(img src / a href,单双引号皆可) */
|
||||||
|
private static final Pattern ASSET_URL_PATTERN =
|
||||||
|
Pattern.compile("(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
|
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
|
||||||
private static final List<String> ALLOWED_EXTS = Arrays.asList(
|
private static final List<String> ALLOWED_EXTS = Arrays.asList(
|
||||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
|
||||||
@@ -56,8 +66,13 @@ public class AttachmentService {
|
|||||||
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
||||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
||||||
|
|
||||||
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */
|
/**
|
||||||
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site");
|
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
|
||||||
|
* 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", "person");
|
||||||
|
|
||||||
private final CmsAttachmentRepository attachmentRepository;
|
private final CmsAttachmentRepository attachmentRepository;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
@@ -69,12 +84,13 @@ public class AttachmentService {
|
|||||||
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
||||||
private String fileWebPath;
|
private String fileWebPath;
|
||||||
|
|
||||||
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */
|
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
|
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
|
||||||
CmsSite site = siteService.requireManageable(user, siteId);
|
CmsSite site = siteService.requireManageable(user, siteId);
|
||||||
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
|
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site)");
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"scene 非法:" + scene + "(仅 banner/link/site/category)");
|
||||||
}
|
}
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
||||||
@@ -155,6 +171,64 @@ public class AttachmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。
|
||||||
|
*
|
||||||
|
* <p>兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空
|
||||||
|
* (外链、data: 等一律不参与场景引用)。
|
||||||
|
*/
|
||||||
|
public Optional<String> localWebUrl(String url) {
|
||||||
|
if (!StringUtils.hasText(url) || !StringUtils.hasText(fileWebPath)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
int start = url.indexOf(fileWebPath);
|
||||||
|
return start < 0 ? Optional.empty() : Optional.of(url.substring(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 抽出 HTML 中指向本站上传目录的资产地址(img src / a href),已归一为 webUrl。
|
||||||
|
*
|
||||||
|
* <p>用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。
|
||||||
|
*/
|
||||||
|
public Set<String> localAssetUrls(String html) {
|
||||||
|
Set<String> webUrls = new LinkedHashSet<>();
|
||||||
|
if (!StringUtils.hasText(html)) {
|
||||||
|
return webUrls;
|
||||||
|
}
|
||||||
|
Matcher matcher = ASSET_URL_PATTERN.matcher(html);
|
||||||
|
while (matcher.find()) {
|
||||||
|
localWebUrl(matcher.group(1)).ifPresent(webUrls::add);
|
||||||
|
}
|
||||||
|
return webUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内容派生引用的全量同步(事项库 R2):把 (refType, refId) 下的引用对齐到给定 webUrl 集合——
|
||||||
|
* 集合内标记归属、集合外解除归属(转回未使用),幂等。
|
||||||
|
*
|
||||||
|
* <p>为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步,
|
||||||
|
* 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int syncAssets(Integer siteId, String refType, Integer refId, Set<String> webUrls) {
|
||||||
|
Set<String> desired = webUrls == null ? Collections.emptySet() : webUrls;
|
||||||
|
List<CmsAttachment> stale = new ArrayList<>();
|
||||||
|
for (CmsAttachment a : attachmentRepository.findByRefTypeAndRefId(refType, refId)) {
|
||||||
|
if (!desired.contains(a.getWebUrl())) {
|
||||||
|
a.setRefType(null);
|
||||||
|
a.setRefId(null);
|
||||||
|
stale.add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stale.isEmpty()) {
|
||||||
|
attachmentRepository.saveAll(stale);
|
||||||
|
}
|
||||||
|
for (String webUrl : desired) {
|
||||||
|
markUsed(siteId, webUrl, refType, refId);
|
||||||
|
}
|
||||||
|
return desired.size();
|
||||||
|
}
|
||||||
|
|
||||||
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
|
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public void release(String refType, Integer refId) {
|
public void release(String refType, Integer refId) {
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ public class BannerService {
|
|||||||
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
|
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
|
||||||
}
|
}
|
||||||
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
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);
|
banner = bannerRepository.save(banner);
|
||||||
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
|
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
|
||||||
attachmentService.release("banner", banner.getId());
|
attachmentService.release("banner", banner.getId());
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ import java.util.stream.Collectors;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CategoryService {
|
public class CategoryService {
|
||||||
|
|
||||||
|
/** 首页展示形态白名单(docs/11;空=主题默认卡) */
|
||||||
|
private static final java.util.Set<String> HOME_STYLE_VALUES =
|
||||||
|
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
|
||||||
|
|
||||||
|
/** 祖先链上溯上限(防脏数据自环导致死循环;正常栏目树远低于此深度) */
|
||||||
|
private static final int MAX_ANCESTOR_WALK = 64;
|
||||||
|
|
||||||
private final CmsCategoryRepository categoryRepository;
|
private final CmsCategoryRepository categoryRepository;
|
||||||
private final CmsArticleRepository articleRepository;
|
private final CmsArticleRepository articleRepository;
|
||||||
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
|
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
|
||||||
@@ -70,7 +77,10 @@ public class CategoryService {
|
|||||||
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
|
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
|
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
|
||||||
}
|
}
|
||||||
CmsCategory category;
|
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;
|
||||||
if (req.getId() == null) {
|
if (req.getId() == null) {
|
||||||
category = new CmsCategory();
|
category = new CmsCategory();
|
||||||
category.setSiteId(site.getId());
|
category.setSiteId(site.getId());
|
||||||
@@ -87,6 +97,9 @@ public class CategoryService {
|
|||||||
category.setType(type.getCode());
|
category.setType(type.getCode());
|
||||||
category.setLinkUrl(req.getLinkUrl());
|
category.setLinkUrl(req.getLinkUrl());
|
||||||
category.setTemplate(req.getTemplate());
|
category.setTemplate(req.getTemplate());
|
||||||
|
// F3(docs/20 §四):「首页展示形态」唯一入口收敛到首页布局编辑器,栏目管理不再编辑该字段——
|
||||||
|
// 入参缺省时保留已有值(存量数据不清除,无布局站点的回落渲染仍消费);显式传值仍校验(API 兼容)
|
||||||
|
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : category.getHomeStyle());
|
||||||
category.setContent(HtmlSanitizer.clean(req.getContent()));
|
category.setContent(HtmlSanitizer.clean(req.getContent()));
|
||||||
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||||
category.setSeoTitle(req.getSeoTitle());
|
category.setSeoTitle(req.getSeoTitle());
|
||||||
@@ -110,9 +123,29 @@ public class CategoryService {
|
|||||||
if (!parent.getSiteId().equals(req.getSiteId())) {
|
if (!parent.getSiteId().equals(req.getSiteId())) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
|
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
|
||||||
}
|
}
|
||||||
if (category.getId() != null && parent.getParentId() != null
|
requireNoCycle(category, parent);
|
||||||
&& parent.getParentId().equals(category.getId())) {
|
}
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "不支持把子栏目设为父栏目(成环)");
|
|
||||||
|
/**
|
||||||
|
* 防环:从目标父栏目沿<b>祖先链</b>上溯,遇到"正在保存的栏目自身"即为环。
|
||||||
|
*
|
||||||
|
* <p>原实现只挡一级父子互换(parent.parentId == 自己),三级及以上的环写得进去;
|
||||||
|
* 环会让整枝栏目在门户导航与栏目树上消失,故补成任意层级(前端下拉同步放开置灰,
|
||||||
|
* 改为保存前同口径校验 + 明确提示,前后端口径一致)。
|
||||||
|
*/
|
||||||
|
private void requireNoCycle(CmsCategory category, CmsCategory parent) {
|
||||||
|
if (category.getId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int guard = 0;
|
||||||
|
CmsCategory cursor = parent;
|
||||||
|
while (cursor != null && guard++ <= MAX_ANCESTOR_WALK) {
|
||||||
|
if (category.getId().equals(cursor.getId())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"不支持把栏目挂到自身或自己的子栏目下(会成环)");
|
||||||
|
}
|
||||||
|
Integer next = cursor.getParentId();
|
||||||
|
cursor = (next == null || next == 0) ? null : categoryRepository.findById(next).orElse(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.yunhai.cms.service.impl;
|
package com.yunhai.cms.service.impl;
|
||||||
|
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaModule;
|
import com.yunhai.platform.utils.model.BoaModule;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -12,11 +12,11 @@ import java.util.Collections;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户中心代理(Notice UserCenterService 同款逻辑平移):
|
* 用户中心代理(Notice UserCenterService 同款逻辑平移):
|
||||||
* 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),扁平转树对齐前端 layout 契约。
|
* 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),层级由用户中心 {@code BoaModule.children}
|
||||||
|
* 直接下发,本侧仅映射为前端 layout 契约(modId/page/children)。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -40,18 +40,18 @@ public class CmsUserCenterService {
|
|||||||
if (modules == null || modules.isEmpty()) {
|
if (modules == null || modules.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
return buildModuleTree(modules);
|
return toFrontendTree(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望。
|
* BoaModule 树 → 前端 layout 期望的字段形状(递归,<b>只改名不组树</b>;Notice/svc-hall 同款口径)。<br>
|
||||||
* 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK 返回顺序。
|
* 层级与兄弟顺序均由用户中心决定(customId 升序),本侧不重排;叶子 children 为空列表。
|
||||||
*/
|
*/
|
||||||
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
private List<Map<String, Object>> toFrontendTree(List<BoaModule> modules) {
|
||||||
Map<Integer, BoaModule> moduleMap = modules.stream()
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
|
if (modules == null || modules.isEmpty()) {
|
||||||
|
return result;
|
||||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
}
|
||||||
for (BoaModule m : modules) {
|
for (BoaModule m : modules) {
|
||||||
Map<String, Object> node = new LinkedHashMap<>();
|
Map<String, Object> node = new LinkedHashMap<>();
|
||||||
node.put("id", m.getId());
|
node.put("id", m.getId());
|
||||||
@@ -63,22 +63,9 @@ public class CmsUserCenterService {
|
|||||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||||
node.put("usable", true);
|
node.put("usable", true);
|
||||||
node.put("children", new ArrayList<>());
|
node.put("children", toFrontendTree(m.getChildren()));
|
||||||
nodeMap.put(m.getId(), node);
|
result.add(node);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
List<Map<String, Object>> roots = new ArrayList<>();
|
|
||||||
for (BoaModule m : modules) {
|
|
||||||
Map<String, Object> node = nodeMap.get(m.getId());
|
|
||||||
int parentId = m.getParentId() != null ? m.getParentId() : 0;
|
|
||||||
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
|
|
||||||
roots.add(node);
|
|
||||||
} else {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
|
||||||
children.add(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return roots;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import com.yunhai.cms.repository.CmsBannerRepository;
|
|||||||
import com.yunhai.cms.repository.CmsCategoryRepository;
|
import com.yunhai.cms.repository.CmsCategoryRepository;
|
||||||
import com.yunhai.cms.repository.CmsLinkRepository;
|
import com.yunhai.cms.repository.CmsLinkRepository;
|
||||||
import com.yunhai.cms.repository.CmsSiteRepository;
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
|
import com.yunhai.cms.utils.ExtJsonUtils;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
@@ -53,16 +54,45 @@ public class PortalQueryService {
|
|||||||
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
|
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
|
||||||
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
|
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
|
||||||
public PortalSiteSnapshot siteSnapshot(String siteCode) {
|
public PortalSiteSnapshot siteSnapshot(String siteCode) {
|
||||||
CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
|
CmsSite site = siteRepository.findFirstByCodeOrderByIdAsc(siteCode).orElse(null);
|
||||||
|
return assembleSnapshot(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点快照(W4:按门户 path 解析,/portal/{path}/** 身份唯一入口)。
|
||||||
|
* 复用 cms:site 缓存命名空间(key=path);失效由既有 SiteContentChangedEvent 整缓存 clear
|
||||||
|
* 覆盖(PortalCacheEvictListener),T-W4-14。
|
||||||
|
*/
|
||||||
|
@Cacheable(cacheNames = CacheNames.SITE, key = "#path")
|
||||||
|
public PortalSiteSnapshot siteSnapshotByPath(String path) {
|
||||||
|
CmsSite site = siteRepository.findByPath(path).orElse(null);
|
||||||
|
return assembleSnapshot(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 快照装配(siteSnapshot/siteSnapshotByPath 共用;站点不存在/停用返回 null 缓存哨兵) */
|
||||||
|
private PortalSiteSnapshot assembleSnapshot(CmsSite site) {
|
||||||
if (site == null || site.getStatus() == null || site.getStatus() != 1) {
|
if (site == null || site.getStatus() == null || site.getStatus() != 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site);
|
PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site);
|
||||||
snapshot.setTheme(resolveTheme(snapshot.getTheme()));
|
String originalTheme = snapshot.getTheme();
|
||||||
|
snapshot.setTheme(resolveTheme(originalTheme, snapshot.getCode()));
|
||||||
|
derivePublicBaseUrl(site, snapshot);
|
||||||
// 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致
|
// 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致
|
||||||
com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme());
|
com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme());
|
||||||
java.util.Map<String, String> themeParams =
|
java.util.Map<String, String> themeParams =
|
||||||
themeInfo.filterValidValues(rawParamsMap(snapshot));
|
themeInfo.filterValidValues(rawParamsMap(snapshot));
|
||||||
|
// docs/20 E(R-46):站点用下架主题 → 强制对应 colorScheme 预设(保持原视觉近似);
|
||||||
|
// 站点保存归一化后 theme 已是保留主题,改由 config.theme_params.colorScheme 站点级驱动
|
||||||
|
com.yunhai.cms.theme.ThemeRegistry.AliasInfo alias =
|
||||||
|
com.yunhai.cms.theme.ThemeRegistry.aliasOf(originalTheme);
|
||||||
|
String siteScheme = siteColorScheme(snapshot.getConfig());
|
||||||
|
if (alias != null && alias.colorScheme != null) {
|
||||||
|
themeParams.put("colorScheme", alias.colorScheme);
|
||||||
|
} else if (siteScheme != null && themeInfo.param("colorScheme") != null
|
||||||
|
&& themeInfo.param("colorScheme").isValidValue(siteScheme)) {
|
||||||
|
themeParams.put("colorScheme", siteScheme);
|
||||||
|
}
|
||||||
snapshot.setThemeParams(themeParams);
|
snapshot.setThemeParams(themeParams);
|
||||||
snapshot.setThemeVarsCss(com.yunhai.cms.theme.ThemeVarsCss.build(themeInfo, themeParams));
|
snapshot.setThemeVarsCss(com.yunhai.cms.theme.ThemeVarsCss.build(themeInfo, themeParams));
|
||||||
|
|
||||||
@@ -176,6 +206,7 @@ public class PortalQueryService {
|
|||||||
item.setTitle(a.getTitle());
|
item.setTitle(a.getTitle());
|
||||||
item.setSummary(a.getSummary());
|
item.setSummary(a.getSummary());
|
||||||
item.setCoverImage(a.getCoverImage());
|
item.setCoverImage(a.getCoverImage());
|
||||||
|
item.setSource(a.getSource());
|
||||||
item.setPublishTime(a.getPublishTime());
|
item.setPublishTime(a.getPublishTime());
|
||||||
item.setTopmost(a.getTopmost());
|
item.setTopmost(a.getTopmost());
|
||||||
data.getItems().add(item);
|
data.getItems().add(item);
|
||||||
@@ -228,24 +259,80 @@ public class PortalQueryService {
|
|||||||
item.setTitle(a.getTitle());
|
item.setTitle(a.getTitle());
|
||||||
item.setSummary(a.getSummary());
|
item.setSummary(a.getSummary());
|
||||||
item.setCoverImage(a.getCoverImage());
|
item.setCoverImage(a.getCoverImage());
|
||||||
|
item.setSource(a.getSource());
|
||||||
item.setPublishTime(a.getPublishTime());
|
item.setPublishTime(a.getPublishTime());
|
||||||
item.setTopmost(a.getTopmost());
|
item.setTopmost(a.getTopmost());
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */
|
/**
|
||||||
private String resolveTheme(String theme) {
|
* D4(W4):public_base_url 继承派生——子站未配置且主站配置了 public_base_url 时,
|
||||||
|
* 派生为 {主站public_base_url}/{本站code};子站自配优先(T-W4-15)。
|
||||||
|
* 支撑"学校独立域名按 /portal/{schoolPath}/** 前缀整段代理"形态:子站链接自动正确。
|
||||||
|
*/
|
||||||
|
private void derivePublicBaseUrl(CmsSite site, PortalSiteSnapshot snapshot) {
|
||||||
|
if (snapshot.getPublicBaseUrl() != null && !snapshot.getPublicBaseUrl().trim().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (snapshot.getParentSiteId() == null || snapshot.getParentSiteId() <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CmsSite parent = siteRepository.findById(snapshot.getParentSiteId()).orElse(null);
|
||||||
|
if (parent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String parentBase = ExtJsonUtils.getString(ExtJsonUtils.parse(parent.getConfigJson()), "public_base_url");
|
||||||
|
if (parentBase != null && !parentBase.trim().isEmpty()) {
|
||||||
|
String base = parentBase.trim();
|
||||||
|
while (base.endsWith("/")) {
|
||||||
|
base = base.substring(0, base.length() - 1);
|
||||||
|
}
|
||||||
|
snapshot.setPublicBaseUrl(base + "/" + site.getCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 皮肤包存在性检查:委托 ThemeRegistry;docs/20 E 后下架主题按别名表映射到保留主题
|
||||||
|
* (R-46:门户不断渲染),未知主题回退 default。
|
||||||
|
*/
|
||||||
|
private String resolveTheme(String theme, String siteCode) {
|
||||||
if (themeRegistry.contains(theme)) {
|
if (themeRegistry.contains(theme)) {
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
log.warn("皮肤包 {} 未注册,回退 default", theme);
|
com.yunhai.cms.theme.ThemeRegistry.AliasInfo alias =
|
||||||
|
com.yunhai.cms.theme.ThemeRegistry.aliasOf(theme);
|
||||||
|
if (alias != null) {
|
||||||
|
log.warn("皮肤包 {} 已下架(docs/20 E 清场),映射到 {}(colorScheme={})site={}",
|
||||||
|
theme, alias.target, alias.colorScheme, siteCode);
|
||||||
|
return alias.target;
|
||||||
|
}
|
||||||
|
log.warn("皮肤包 {} 未注册(site={}),回退 default", theme, siteCode);
|
||||||
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
|
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** config_json.theme_params 原始值(非对象形态按无参数处理) */
|
/** 站点级 colorScheme(config_json.theme_params.colorScheme,仅归一化保存后的别名站点使用) */
|
||||||
|
private static String siteColorScheme(java.util.Map<String, Object> config) {
|
||||||
|
if (config == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object tp = config.get("theme_params");
|
||||||
|
if (tp instanceof Map) {
|
||||||
|
Object v = ((Map<?, ?>) tp).get("colorScheme");
|
||||||
|
if (v != null) {
|
||||||
|
String s = String.valueOf(v);
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参数原始值(工作项 T:参数微调上移为皮肤包级):取当前主题的包级 {@code paramValues}
|
||||||
|
* ({@code themes/.meta/{name}.json} 覆盖层)。站点 {@code config_json.theme_params} 已退役、不再读取
|
||||||
|
* (存量残留由 SiteService 的 RESERVED 键容忍,不影响门户)。
|
||||||
|
*/
|
||||||
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
|
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
|
||||||
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
|
return themeRegistry.paramValuesOf(snapshot.getTheme());
|
||||||
return raw instanceof Map ? (Map<?, ?>) raw : null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import com.yunhai.cms.model.vo.SchoolVo;
|
|||||||
import com.yunhai.cms.model.vo.SiteVo;
|
import com.yunhai.cms.model.vo.SiteVo;
|
||||||
import com.yunhai.cms.repository.CmsArticleRepository;
|
import com.yunhai.cms.repository.CmsArticleRepository;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
|
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
import com.yunhai.platform.utils.model.BoaGroup;
|
import com.yunhai.platform.utils.model.BoaGroup;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
@@ -49,6 +49,7 @@ public class SchoolService {
|
|||||||
vo.setSchName(top.getSchName());
|
vo.setSchName(top.getSchName());
|
||||||
vo.setMainSiteId(top.getId());
|
vo.setMainSiteId(top.getId());
|
||||||
vo.setMainSiteCode(top.getCode());
|
vo.setMainSiteCode(top.getCode());
|
||||||
|
vo.setMainSitePath(top.getPath());
|
||||||
vo.setMainSiteName(top.getName());
|
vo.setMainSiteName(top.getName());
|
||||||
vo.setTheme(top.getTheme());
|
vo.setTheme(top.getTheme());
|
||||||
vo.setStatus(top.getStatus());
|
vo.setStatus(top.getStatus());
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class SiteService {
|
|||||||
private final CmsAuthProperties authProperties;
|
private final CmsAuthProperties authProperties;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
|
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
|
||||||
|
private final com.yunhai.cms.repository.CmsCategoryRepository categoryRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
|
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
|
||||||
@@ -59,6 +60,40 @@ public class SiteService {
|
|||||||
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
||||||
private static final int CONFIG_VALUE_MAX_LEN = 500;
|
private static final int CONFIG_VALUE_MAX_LEN = 500;
|
||||||
|
|
||||||
|
/** home_layout(docs/16 §4.6 + v2 手册 §2.8 + docs/20 D 组件库扩展)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
|
||||||
|
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
|
||||||
|
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
|
||||||
|
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
|
||||||
|
// 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):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
|
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
|
||||||
private static final int CUSTOM_CSS_MAX_LEN = 10240;
|
private static final int CUSTOM_CSS_MAX_LEN = 10240;
|
||||||
private static final List<String> CUSTOM_CSS_BLACKLIST =
|
private static final List<String> CUSTOM_CSS_BLACKLIST =
|
||||||
@@ -112,7 +147,7 @@ public class SiteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public CmsSite getByCode(String code) {
|
public CmsSite getByCode(String code) {
|
||||||
return siteRepository.findByCode(code).orElse(null);
|
return siteRepository.findFirstByCodeOrderByIdAsc(code).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String configValue(CmsSite site, String key) {
|
public String configValue(CmsSite site, String key) {
|
||||||
@@ -120,22 +155,34 @@ public class SiteService {
|
|||||||
return ExtJsonUtils.getString(config, key);
|
return ExtJsonUtils.getString(config, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */
|
/** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主)
|
||||||
|
* W4 嵌套路径(方案 §8.4 步骤 3):validateParent 后计算 path(顶层=code;子站=parent.path+'/'+code),
|
||||||
|
* existsByPath 冲突拒绝(同父同码冲突=D2 uk_site_path 兜底);编辑分支 code 不可变(T-W4-05);改挂自然重算(T-W4-06)。 */
|
||||||
@Transactional
|
@Transactional
|
||||||
public SiteVo save(BoaUser user, SiteSaveRequest req) {
|
public SiteVo save(BoaUser user, SiteSaveRequest req) {
|
||||||
requireSiteSavePermission(user, req);
|
requireSiteSavePermission(user, req);
|
||||||
CmsSite site;
|
CmsSite site;
|
||||||
if (req.getId() == null) {
|
if (req.getId() == null) {
|
||||||
if (siteRepository.findByCode(req.getCode()).isPresent()) {
|
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在");
|
|
||||||
}
|
|
||||||
site = new CmsSite();
|
site = new CmsSite();
|
||||||
site.setCode(req.getCode());
|
site.setCode(req.getCode());
|
||||||
} else {
|
} else {
|
||||||
site = siteRepository.findById(req.getId())
|
site = siteRepository.findById(req.getId())
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
|
||||||
|
// W4:编码=URL 末段,创建后不可修改(对齐前端 SiteManage 编辑弹窗 code disabled)
|
||||||
|
if (!site.getCode().equals(req.getCode())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "编码创建后不可修改");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
validateParent(req, site);
|
validateParent(req, site);
|
||||||
|
// W4:path 计算(唯一入口,SitePathBuilder 语义收敛于此)与冲突拒绝
|
||||||
|
String path = buildPath(site.getParentSiteId(), site.getCode());
|
||||||
|
boolean pathConflict = site.getId() == null
|
||||||
|
? siteRepository.existsByPath(path)
|
||||||
|
: siteRepository.existsByPathAndIdNot(path, site.getId());
|
||||||
|
if (pathConflict) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "路径冲突:" + path + "(同上级下编码重复或地址已被占用)");
|
||||||
|
}
|
||||||
|
site.setPath(path);
|
||||||
// B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配,
|
// B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配,
|
||||||
// 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响)
|
// 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响)
|
||||||
if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) {
|
if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) {
|
||||||
@@ -144,14 +191,30 @@ public class SiteService {
|
|||||||
site.setSchId(req.getSchId());
|
site.setSchId(req.getSchId());
|
||||||
site.setSchName(req.getSchName());
|
site.setSchName(req.getSchName());
|
||||||
site.setName(req.getName());
|
site.setName(req.getName());
|
||||||
String resolvedTheme = StringUtils.hasText(req.getTheme())
|
String requestedTheme = StringUtils.hasText(req.getTheme())
|
||||||
? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
|
? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
|
||||||
|
// docs/20 E:下架主题名按别名表归一化为保留主题(保存后 theme 字段收敛到 3 套注册包)
|
||||||
|
com.yunhai.cms.theme.ThemeRegistry.AliasInfo alias =
|
||||||
|
com.yunhai.cms.theme.ThemeRegistry.aliasOf(requestedTheme);
|
||||||
|
String resolvedTheme = requestedTheme;
|
||||||
if (!themeRegistry.contains(resolvedTheme)) {
|
if (!themeRegistry.contains(resolvedTheme)) {
|
||||||
|
if (alias != null) {
|
||||||
|
resolvedTheme = alias.target;
|
||||||
|
} else {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
site.setTheme(resolvedTheme);
|
site.setTheme(resolvedTheme);
|
||||||
validateConfigJson(req.getConfigJson());
|
String configJson = req.getConfigJson();
|
||||||
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
|
if (alias != null && alias.colorScheme != null) {
|
||||||
|
// 原配色写入站点级 theme_params.colorScheme(渲染端据此套用预设,保留原视觉近似)
|
||||||
|
configJson = withSiteColorScheme(configJson, alias.colorScheme);
|
||||||
|
req.setConfigJson(configJson);
|
||||||
|
}
|
||||||
|
validateConfigJson(configJson, site.getId());
|
||||||
|
// 工作项 T:参数微调上移为皮肤包级,站点不再持有参数值——themeParams 入参直接忽略
|
||||||
|
// (config_json 原样入库;theme_params 仍是 RESERVED 键以容忍存量/老前端提交,不报错)
|
||||||
|
site.setConfigJson(req.getConfigJson());
|
||||||
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
site = siteRepository.save(site);
|
site = siteRepository.save(site);
|
||||||
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
|
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
|
||||||
@@ -160,6 +223,39 @@ public class SiteService {
|
|||||||
return SiteVo.of(site);
|
return SiteVo.of(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/20 E:站点级 colorScheme 写入 config_json.theme_params(仅 colorScheme 键,
|
||||||
|
* 其余 theme_params 内容原样保留;theme_params 为 RESERVED 键不参与常规校验)。
|
||||||
|
*/
|
||||||
|
private String withSiteColorScheme(String configJson, String colorScheme) {
|
||||||
|
java.util.Map<String, Object> config = ExtJsonUtils.parse(configJson);
|
||||||
|
Object tp = config.get("theme_params");
|
||||||
|
java.util.Map<String, Object> themeParams = new java.util.LinkedHashMap<>();
|
||||||
|
if (tp instanceof java.util.Map) {
|
||||||
|
((java.util.Map<?, ?>) tp).forEach((k, v) -> themeParams.put(String.valueOf(k), v));
|
||||||
|
}
|
||||||
|
themeParams.put("colorScheme", colorScheme);
|
||||||
|
config.put("theme_params", themeParams);
|
||||||
|
try {
|
||||||
|
return CONFIG_MAPPER.writeValueAsString(config);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W4 门户 path 计算(唯一入口,前后端各一份纯函数语义:front/src/utils/sitePath.ts 同构):
|
||||||
|
* 顶层(parentSiteId≤0)=code;子站=父站 path + '/' + code。
|
||||||
|
*/
|
||||||
|
private String buildPath(Integer parentSiteId, String code) {
|
||||||
|
if (parentSiteId == null || parentSiteId <= 0) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
CmsSite parent = siteRepository.findById(parentSiteId)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
|
||||||
|
return parent.getPath() + "/" + code;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
|
* 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
|
||||||
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
|
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
|
||||||
@@ -204,29 +300,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, "站点配置序列化失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
|
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
|
||||||
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
|
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
|
||||||
@@ -262,11 +335,12 @@ public class SiteService {
|
|||||||
/**
|
/**
|
||||||
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
|
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
|
||||||
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
|
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
|
||||||
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
|
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
|
||||||
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
|
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
|
||||||
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
|
* 系统托管键 theme_params 直通;home_layout 走结构校验(docs/16 §4.6)——
|
||||||
|
* 此前「非字符串值原样放行」的口子收紧到该键;其余非字符串值(wechat 等预留对象)不校验原样放行。
|
||||||
*/
|
*/
|
||||||
private void validateConfigJson(String configJson) {
|
private void validateConfigJson(String configJson, Integer siteId) {
|
||||||
if (!StringUtils.hasText(configJson)) {
|
if (!StringUtils.hasText(configJson)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -289,6 +363,10 @@ public class SiteService {
|
|||||||
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
|
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
|
||||||
}
|
}
|
||||||
JsonNode value = entry.getValue();
|
JsonNode value = entry.getValue();
|
||||||
|
if ("home_layout".equals(key)) {
|
||||||
|
validateHomeLayout(value, siteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!value.isTextual()) {
|
if (!value.isTextual()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -312,6 +390,11 @@ public class SiteService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "public_base_url":
|
case "public_base_url":
|
||||||
|
case "svc_hall_base":
|
||||||
|
case "social_wechat":
|
||||||
|
case "social_weibo":
|
||||||
|
case "social_douyin":
|
||||||
|
case "social_bilibili":
|
||||||
if (!URL_PATTERN.matcher(text).matches()) {
|
if (!URL_PATTERN.matcher(text).matches()) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
"对外基地址必须是完整的 http(s):// 地址:" + text);
|
"对外基地址必须是完整的 http(s):// 地址:" + text);
|
||||||
@@ -319,6 +402,9 @@ public class SiteService {
|
|||||||
break;
|
break;
|
||||||
case "logo":
|
case "logo":
|
||||||
case "favicon":
|
case "favicon":
|
||||||
|
case "qr_wechat":
|
||||||
|
case "qr_weibo":
|
||||||
|
case "news_bg_image":
|
||||||
if (!ASSET_PATTERN.matcher(text).matches()) {
|
if (!ASSET_PATTERN.matcher(text).matches()) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
|
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
|
||||||
@@ -330,6 +416,282 @@ public class SiteService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
|
||||||
|
* 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(尚无栏目可引)。
|
||||||
|
*/
|
||||||
|
private void validateHomeLayout(JsonNode node, Integer siteId) {
|
||||||
|
if (!node.isObject()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局必须是 JSON 对象({version, blocks})");
|
||||||
|
}
|
||||||
|
if (node.has("version") && !node.get("version").isInt()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 version 必须是整数");
|
||||||
|
}
|
||||||
|
JsonNode blocks = node.get("blocks");
|
||||||
|
if (blocks == null || !blocks.isArray()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 blocks 必须是数组");
|
||||||
|
}
|
||||||
|
if (blocks.size() > HOME_LAYOUT_MAX_BLOCKS) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块数量不能超过 " + HOME_LAYOUT_MAX_BLOCKS + " 个");
|
||||||
|
}
|
||||||
|
Set<Integer> referencedIds = new java.util.HashSet<>();
|
||||||
|
for (JsonNode block : blocks) {
|
||||||
|
if (!block.isObject()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块必须是对象");
|
||||||
|
}
|
||||||
|
if (block.size() > HOME_LAYOUT_MAX_BLOCK_KEYS) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局单个区块键数不能超过 " + HOME_LAYOUT_MAX_BLOCK_KEYS + " 个");
|
||||||
|
}
|
||||||
|
JsonNode type = block.get("type");
|
||||||
|
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)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块条数须在 " + HOME_LAYOUT_SIZE_MIN + "~" + HOME_LAYOUT_SIZE_MAX + " 之间");
|
||||||
|
}
|
||||||
|
for (String textKey : new String[]{"title", "subtitle"}) {
|
||||||
|
JsonNode text = block.get(textKey);
|
||||||
|
if (text != null && (!text.isTextual() || text.asText().length() > HOME_LAYOUT_MAX_TEXT)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块 " + textKey + " 须为不超过 " + HOME_LAYOUT_MAX_TEXT + " 字的文本");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JsonNode categoryIds = block.get("categoryIds");
|
||||||
|
if (categoryIds != null) {
|
||||||
|
if (!categoryIds.isArray()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须是数组");
|
||||||
|
}
|
||||||
|
for (JsonNode id : categoryIds) {
|
||||||
|
if (!id.isNumber() || id.intValue() <= 0) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须为正整数");
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "站点尚未创建,首页布局不能引用栏目");
|
||||||
|
}
|
||||||
|
// 防跨站注入:引用的栏目必须属于本站点
|
||||||
|
List<com.yunhai.cms.entity.CmsCategory> cats = categoryRepository.findAllById(referencedIds);
|
||||||
|
for (com.yunhai.cms.entity.CmsCategory c : cats) {
|
||||||
|
if (!siteId.equals(c.getSiteId())) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不属于本站点的栏目(id=" + c.getId() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cats.size() < referencedIds.size()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不存在的栏目");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;黑名单忽略大小写拒绝
|
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
|
||||||
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
|
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
|
||||||
|
|||||||
@@ -18,16 +18,36 @@ import java.util.List;
|
|||||||
public class ThemeService {
|
public class ThemeService {
|
||||||
|
|
||||||
private final ThemeRegistry themeRegistry;
|
private final ThemeRegistry themeRegistry;
|
||||||
|
private final com.yunhai.cms.repository.CmsSiteRepository siteRepository;
|
||||||
|
|
||||||
/** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用 */
|
/** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用。
|
||||||
|
* W1(G1/T-W1-14):增 source/inUseCount/editable——来源与删除保护口径同源(findByTheme)。 */
|
||||||
public List<ThemeVo> list() {
|
public List<ThemeVo> list() {
|
||||||
List<ThemeVo> result = new ArrayList<>();
|
List<ThemeVo> result = new ArrayList<>();
|
||||||
for (ThemeInfo info : themeRegistry.list()) {
|
for (ThemeInfo info : themeRegistry.list()) {
|
||||||
result.add(ThemeVo.of(info));
|
result.add(voOf(info));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 单个包 VO 装配(来源/被用数/可编辑随 Registry sourceOf 与站点仓储实时装配) */
|
||||||
|
public ThemeVo voOf(ThemeInfo info) {
|
||||||
|
ThemeVo vo = ThemeVo.of(info);
|
||||||
|
String source = themeRegistry.sourceOf(info.getName()) == null
|
||||||
|
? ThemeRegistry.SOURCE_BUILTIN : themeRegistry.sourceOf(info.getName());
|
||||||
|
vo.setSource(source);
|
||||||
|
java.util.List<com.yunhai.cms.entity.CmsSite> usedBy = siteRepository.findByTheme(info.getName());
|
||||||
|
vo.setInUseCount(usedBy.size());
|
||||||
|
java.util.List<String> usedNames = new ArrayList<>();
|
||||||
|
for (com.yunhai.cms.entity.CmsSite s : usedBy) {
|
||||||
|
usedNames.add(s.getName());
|
||||||
|
}
|
||||||
|
vo.setUsedBySiteNames(usedNames);
|
||||||
|
// 任务四:语义改为「可编辑元数据」——内置包亦可改显示名/描述(.meta 覆盖层),内容仍只读
|
||||||
|
vo.setEditable(true);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
/** 未注册主题返回 null(白名单判断用) */
|
/** 未注册主题返回 null(白名单判断用) */
|
||||||
public ThemeInfo get(String name) {
|
public ThemeInfo get(String name) {
|
||||||
return themeRegistry.get(name);
|
return themeRegistry.get(name);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.query;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户服务中心列表查询(事项库详细设计 §5.2:全新查询面——obj/cat/dept 三维任意组合 + 关键词)。
|
||||||
|
* 空即不筛;状态固定 status=1(服务端收口,不由调用方传入)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemPortalQuery {
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
/** 服务对象 dict_key(可空) */
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
/** 事项类别 dict_key(可空) */
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
/** 服务部门组节点 id(可空) */
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
/** 关键词(title/summary LIKE,可空) */
|
||||||
|
private String keyword;
|
||||||
|
|
||||||
|
/** 页码,1 起 */
|
||||||
|
private Integer page = 1;
|
||||||
|
|
||||||
|
/** 页长 */
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户服务中心三组筛选项 + 计数(已发布口径,事项库详细设计 §5.2/§6.1)。
|
||||||
|
* 本对象为 cms:svcItem 缓存体(key=filters:{siteId}):仅承载选项与计数,
|
||||||
|
* active/url 由控制器按当前请求装配为 Facet(缓存体不可被请求态污染)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalServiceFilters {
|
||||||
|
|
||||||
|
/** 服务对象(object 字典,仅启用且有已发布事项引用的键) */
|
||||||
|
private List<Option> objects = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 事项类别(category 字典,同上) */
|
||||||
|
private List<Option> categories = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 服务部门(有已发布事项引用的组节点) */
|
||||||
|
private List<DeptOption> depts = new ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Option {
|
||||||
|
|
||||||
|
private final String key;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
public Option(String key, String name, long count) {
|
||||||
|
this.key = key;
|
||||||
|
this.name = name;
|
||||||
|
this.count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class DeptOption {
|
||||||
|
|
||||||
|
private final Integer id;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
public DeptOption(Integer id, String name, long count) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.count = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板消费形态:{code|id, name, count, active, url}——url 由控制器预生成,th:href 直用 */
|
||||||
|
@Data
|
||||||
|
public static class Facet {
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
private final Integer id;
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
private final long count;
|
||||||
|
|
||||||
|
private final boolean active;
|
||||||
|
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
public static Facet ofKey(String code, String name, long count, boolean active, String url) {
|
||||||
|
return new Facet(code, null, name, count, active, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Facet ofId(Integer id, String name, long count, boolean active, String url) {
|
||||||
|
return new Facet(null, id, name, count, active, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户事项指南页模型(cms:svcItem 缓存体,key=itemId;未发布/不存在缓存 null 哨兵)。
|
||||||
|
* applyUrl 为 ext_json 手填原始值;最终按钮 URL 三态优先级(手填 > svc_hall_base 生成 > 不渲染)
|
||||||
|
* 由 PortalController.doGuide 决定——本缓存体不掺入站点配置。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalServiceItem {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String objectKey;
|
||||||
|
|
||||||
|
private String objectName;
|
||||||
|
|
||||||
|
private String categoryKey;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
private Integer deptGroupId;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private String handlePlace;
|
||||||
|
|
||||||
|
private String handleTimeLimit;
|
||||||
|
|
||||||
|
private String promiseTimeLimit;
|
||||||
|
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
private String supervisePhone;
|
||||||
|
|
||||||
|
/** 已清洗入库内容;渲染期经 url.rewriteUploadUrls 补基准(withRewrittenHtml 拷贝,不污染缓存体) */
|
||||||
|
private String processHtml;
|
||||||
|
|
||||||
|
private String materialHtml;
|
||||||
|
|
||||||
|
private String noticeHtml;
|
||||||
|
|
||||||
|
private Integer faqCategoryId;
|
||||||
|
|
||||||
|
private Integer noticeCategoryId;
|
||||||
|
|
||||||
|
private Integer ruleCategoryId;
|
||||||
|
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
private Integer viewCount;
|
||||||
|
|
||||||
|
/** ext_json.apply_url 原始值(可空) */
|
||||||
|
private String applyUrl;
|
||||||
|
|
||||||
|
/** 绑定流程 GUID(可空;§3.7"自动生成"分支在线条件=onlineFlag==1 且 flowGuid 非空) */
|
||||||
|
private String flowGuid;
|
||||||
|
|
||||||
|
/** copy-on-write:渲染期补基准(同 doArticle 的 detail.withContent 先例,避免污染缓存对象) */
|
||||||
|
public PortalServiceItem withRewrittenHtml(String processHtml, String materialHtml, String noticeHtml) {
|
||||||
|
PortalServiceItem copy = new PortalServiceItem();
|
||||||
|
copy.setId(id);
|
||||||
|
copy.setSiteId(siteId);
|
||||||
|
copy.setItemCode(itemCode);
|
||||||
|
copy.setTitle(title);
|
||||||
|
copy.setSummary(summary);
|
||||||
|
copy.setObjectKey(objectKey);
|
||||||
|
copy.setObjectName(objectName);
|
||||||
|
copy.setCategoryKey(categoryKey);
|
||||||
|
copy.setCategoryName(categoryName);
|
||||||
|
copy.setDeptGroupId(deptGroupId);
|
||||||
|
copy.setDeptName(deptName);
|
||||||
|
copy.setHandleMode(handleMode);
|
||||||
|
copy.setOnlineFlag(onlineFlag);
|
||||||
|
copy.setHandlePlace(handlePlace);
|
||||||
|
copy.setHandleTimeLimit(handleTimeLimit);
|
||||||
|
copy.setPromiseTimeLimit(promiseTimeLimit);
|
||||||
|
copy.setContactPhone(contactPhone);
|
||||||
|
copy.setSupervisePhone(supervisePhone);
|
||||||
|
copy.setProcessHtml(processHtml);
|
||||||
|
copy.setMaterialHtml(materialHtml);
|
||||||
|
copy.setNoticeHtml(noticeHtml);
|
||||||
|
copy.setFaqCategoryId(faqCategoryId);
|
||||||
|
copy.setNoticeCategoryId(noticeCategoryId);
|
||||||
|
copy.setRuleCategoryId(ruleCategoryId);
|
||||||
|
copy.setCoverImage(coverImage);
|
||||||
|
copy.setPublishTime(publishTime);
|
||||||
|
copy.setViewCount(viewCount);
|
||||||
|
copy.setApplyUrl(applyUrl);
|
||||||
|
copy.setFlowGuid(flowGuid);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户服务中心列表数据(事项库详细设计 §5.2/§6.1)。
|
||||||
|
* 字段形态与既有 PortalListData 同构(items + page/totalPages/total),便于模板与契约复用;
|
||||||
|
* pageUrls 由控制器预生成(模板禁止手拼 query,带筛选时手拼必错)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class PortalServiceListData {
|
||||||
|
|
||||||
|
private Integer siteId;
|
||||||
|
|
||||||
|
private Long total = 0L;
|
||||||
|
|
||||||
|
private Integer page = 1;
|
||||||
|
|
||||||
|
private Integer pageSize = 10;
|
||||||
|
|
||||||
|
private Integer totalPages = 0;
|
||||||
|
|
||||||
|
private java.util.List<Item> items = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
/** 分页链接(控制器装配:[{page, url, active}],url 携带当前筛选) */
|
||||||
|
private java.util.List<PageUrl> pageUrls = new java.util.ArrayList<>();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Item {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String itemCode;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
private String coverImage;
|
||||||
|
|
||||||
|
private String deptName;
|
||||||
|
|
||||||
|
private String categoryName;
|
||||||
|
|
||||||
|
/** 1 线上 / 2 线下 / 3 线上线下 */
|
||||||
|
private Integer handleMode;
|
||||||
|
|
||||||
|
/** 1/0("在线办理"标识) */
|
||||||
|
private Integer onlineFlag;
|
||||||
|
|
||||||
|
private LocalDateTime publishTime;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class PageUrl {
|
||||||
|
|
||||||
|
private final int page;
|
||||||
|
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
private final boolean active;
|
||||||
|
|
||||||
|
public PageUrl(int page, String url, boolean active) {
|
||||||
|
this.page = page;
|
||||||
|
this.url = url;
|
||||||
|
this.active = active;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.yunhai.cms.svcitem.model.vo;
|
||||||
|
|
||||||
|
import com.yunhai.cms.entity.CmsAttachment;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指南页附件(ref_type=svc_item, ref_id=事项id;fileName/webUrl/fileSize 三字段契约,§6.2)。
|
||||||
|
* webUrl 为入库相对路径,渲染期由 url.upload(...) 补基准。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SvcItemAttachmentVo {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
private String webUrl;
|
||||||
|
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
public static SvcItemAttachmentVo of(CmsAttachment attachment) {
|
||||||
|
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
|
||||||
|
vo.setId(attachment.getId());
|
||||||
|
vo.setFileName(attachment.getFileName());
|
||||||
|
vo.setWebUrl(attachment.getWebUrl());
|
||||||
|
vo.setFileSize(attachment.getFileSize());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yunhai.cms.svcitem.service;
|
||||||
|
|
||||||
|
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 事项域进程内接缝(V2.0 拆分后收敛为**门户消费面**):
|
||||||
|
* 事项数据与管理能力已迁至独立"办事大厅"应用(svc-hall),CMS 只保留门户主题化展示壳,
|
||||||
|
* 本接口由 {@code SvcItemRemoteQuery}(经签名 oapi 调办事大厅 /oapi/svc/** 开放接口)实现,
|
||||||
|
* PortalController 等消费方零改动(原接口注释"拆分时换 @ConditionalOnProperty 实现"的兑现)。
|
||||||
|
* 原管理端方法与 snapshot 随管理功能/快照口一并迁出,见 docs/19 §九。
|
||||||
|
*/
|
||||||
|
public interface SvcItemQuery {
|
||||||
|
|
||||||
|
/** 门户:服务中心列表分页(三维筛选 + 关键词;实时查询不缓存) */
|
||||||
|
PortalServiceListData portalPage(SvcItemPortalQuery query);
|
||||||
|
|
||||||
|
/** 门户:三组筛选项 + 计数(已发布口径;缓存 filters:{siteId},remote 模式 TTL 过期) */
|
||||||
|
PortalServiceFilters portalFilters(Integer siteId);
|
||||||
|
|
||||||
|
/** 门户:指南页模型(不存在/未发布返回 null,缓存 null 哨兵 30s 防穿透) */
|
||||||
|
PortalServiceItem portalItem(Integer itemId, Integer siteId);
|
||||||
|
|
||||||
|
/** 门户:关联事项(同类别已发布 Top N,排除自身) */
|
||||||
|
List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit);
|
||||||
|
|
||||||
|
/** 门户:指南页附件(办事大厅 svc_item_guide 绑定,fileUrl 已是完整地址) */
|
||||||
|
List<SvcItemAttachmentVo> attachments(Integer itemId);
|
||||||
|
|
||||||
|
/** 门户:指南页浏览量异步累加(转发办事大厅 /oapi/svc/item/view,不承诺精确) */
|
||||||
|
void countView(Integer itemId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.yunhai.cms.svcitem.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||||
|
import com.yunhai.platform.utils.http.IDHttp;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 办事大厅开放接口客户端(V2.0 Part C.3):经 SDK {@link YschOpenCall} 命名目标
|
||||||
|
* {@code svc-hall}(服务目录 {@code yh.open.services.svc-hall})调 svc-hall {@code /oapi/svc/**};
|
||||||
|
* 出站签名/信封解包/错误映射全部由 SDK 承载——本应用零专属地址配置
|
||||||
|
* (《子应用配置优化方案》§4.2)。<br><br>
|
||||||
|
* <b>为何不能再用共享 {@code IDHttp}</b>:svc-hall 带 context-path {@code /svc-hall},而共享
|
||||||
|
* 拦截器只剥 {@code yh.open.base-path}(本应用 {@code /userbase})——原手工拼的 URL 会被签成
|
||||||
|
* {@code /svc-hall/oapi/...},服务端剥掉 ctx 后两侧 target 不一致,<b>必然 401</b>。
|
||||||
|
* {@link YschOpenCall} 的 per-target base-path 与提供方 context-path 对齐(同 svc-hall
|
||||||
|
* {@code CmsSiteDirectoryImpl} 的既有范式)。<br><br>
|
||||||
|
* <b>错误语义</b>:业务失败信封 {@code code!=0} → {@code DException}(携带 OpenApiErrorCode,
|
||||||
|
* 办事大厅"不存在/未发布"= code 2);传输/HTTP 失败 → {@code OpenApiException};401/403 →
|
||||||
|
* {@code DException("验签失败")}。降级判定见 {@code SvcItemRemoteQuery}。<br><br>
|
||||||
|
* 目录未显式配置时回落 legacy {@code yh.svc-hall.base-url}(迁移期兼容);两者皆无则构造期
|
||||||
|
* fail-fast——不给"沉默连不上 localhost"的假象。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class SvcHallClient {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SvcHallClient.class);
|
||||||
|
|
||||||
|
/** 服务目录命名目标(svc-hall 未晋升 PlatformService 枚举,按名直调) */
|
||||||
|
static final String DIRECTORY_KEY = "svc-hall";
|
||||||
|
|
||||||
|
private static final String LEGACY_KEY = "yh.svc-hall.base-url";
|
||||||
|
|
||||||
|
private final YschOpenCall openCall;
|
||||||
|
private final IDHttp http;
|
||||||
|
private final String legacyBaseUrl;
|
||||||
|
private final boolean directoryResolved;
|
||||||
|
|
||||||
|
public SvcHallClient(YschOpenCall openCall,
|
||||||
|
IDHttp http,
|
||||||
|
@Value("${yh.svc-hall.base-url:}") String baseUrl) {
|
||||||
|
this.openCall = openCall;
|
||||||
|
this.http = http;
|
||||||
|
this.directoryResolved = openCall != null && openCall.hasTarget(DIRECTORY_KEY);
|
||||||
|
this.legacyBaseUrl = trimTrailing(baseUrl == null ? "" : baseUrl.trim());
|
||||||
|
if (!directoryResolved && legacyBaseUrl.isEmpty()) {
|
||||||
|
throw new IllegalStateException("办事大厅调用地址未配置:请配置服务目录 yh.open.services."
|
||||||
|
+ DIRECTORY_KEY + "(推荐,容器直连完整 URL)或回落键 " + LEGACY_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否走服务目录命名目标(配置自检用) */
|
||||||
|
public boolean directoryResolved() {
|
||||||
|
return directoryResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /oapi/svc/items → data(分页 + 三维筛选 + 关键词) */
|
||||||
|
public JsonNode items(String siteCode, String objectKey, String categoryKey, Integer deptGroupId,
|
||||||
|
String keyword, Integer page, Integer pageSize) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
put(query, "siteCode", siteCode);
|
||||||
|
put(query, "objectKey", objectKey);
|
||||||
|
put(query, "categoryKey", categoryKey);
|
||||||
|
put(query, "keyword", keyword);
|
||||||
|
if (deptGroupId != null) {
|
||||||
|
query.put("deptGroupId", String.valueOf(deptGroupId));
|
||||||
|
}
|
||||||
|
if (page != null) {
|
||||||
|
query.put("page", String.valueOf(page));
|
||||||
|
}
|
||||||
|
if (pageSize != null) {
|
||||||
|
query.put("pageSize", String.valueOf(pageSize));
|
||||||
|
}
|
||||||
|
return get("/oapi/svc/items", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /oapi/svc/filters → data */
|
||||||
|
public JsonNode filters(String siteCode) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
put(query, "siteCode", siteCode);
|
||||||
|
return get("/oapi/svc/filters", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /oapi/svc/item/detail → data(itemId 或 itemCode 二选一) */
|
||||||
|
public JsonNode detail(String siteCode, Integer itemId, String itemCode) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
put(query, "siteCode", siteCode);
|
||||||
|
if (itemId != null) {
|
||||||
|
query.put("itemId", String.valueOf(itemId));
|
||||||
|
}
|
||||||
|
put(query, "itemCode", itemCode);
|
||||||
|
return get("/oapi/svc/item/detail", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /oapi/svc/item/view(siteCode+itemCode 口径;fire-and-forget,失败仅 warn) */
|
||||||
|
public void view(String siteCode, String itemCode) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
put(query, "siteCode", siteCode);
|
||||||
|
put(query, "itemCode", itemCode);
|
||||||
|
safeView(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /oapi/svc/item/view(itemId 直取口径;fire-and-forget,失败仅 warn) */
|
||||||
|
public void viewByItemId(Integer itemId) {
|
||||||
|
if (itemId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("itemId", String.valueOf(itemId));
|
||||||
|
safeView(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void safeView(Map<String, String> query) {
|
||||||
|
try {
|
||||||
|
if (directoryResolved) {
|
||||||
|
openCall.post(DIRECTORY_KEY, "/oapi/svc/item/view", query, null, JsonNode.class);
|
||||||
|
} else {
|
||||||
|
http.post(legacyUrl("/oapi/svc/item/view", query), null, JsonNode.class);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[cms] 办事大厅浏览量累加失败 {}:{}", query, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /oapi/svc/item/attachments(itemId 直取;fileUrl 已绝对化) */
|
||||||
|
public JsonNode attachments(Integer itemId) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("itemId", String.valueOf(itemId));
|
||||||
|
return get("/oapi/svc/item/attachments", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /oapi/svc/item/related(itemId 直取:同类别已发布 Top N) */
|
||||||
|
public JsonNode related(Integer itemId, Integer limit) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("itemId", String.valueOf(itemId));
|
||||||
|
if (limit != null) {
|
||||||
|
query.put("limit", String.valueOf(limit));
|
||||||
|
}
|
||||||
|
return get("/oapi/svc/item/related", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 出站(目录优先,legacy 回落)=====
|
||||||
|
|
||||||
|
private JsonNode get(String path, Map<String, String> query) {
|
||||||
|
if (directoryResolved) {
|
||||||
|
return openCall.get(DIRECTORY_KEY, path, query, JsonNode.class);
|
||||||
|
}
|
||||||
|
return http.get(legacyUrl(path, query), JsonNode.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** legacy 形态:base-url + path + query(与原手工拼接的语义一致,保住既有调用方预期) */
|
||||||
|
private String legacyUrl(String path, Map<String, String> query) {
|
||||||
|
StringBuilder url = new StringBuilder(legacyBaseUrl).append(path);
|
||||||
|
boolean first = true;
|
||||||
|
for (Map.Entry<String, String> e : query.entrySet()) {
|
||||||
|
url.append(first ? '?' : '&').append(e.getKey()).append('=').append(encode(e.getValue()));
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void put(Map<String, String> query, String key, String value) {
|
||||||
|
if (value != null && !value.isEmpty()) {
|
||||||
|
query.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
try {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
|
||||||
|
} catch (Exception e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTrailing(String url) {
|
||||||
|
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package com.yunhai.cms.svcitem.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.yunhai.cms.entity.CmsSite;
|
||||||
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
|
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
|
||||||
|
import com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo;
|
||||||
|
import com.yunhai.cms.svcitem.service.SvcItemQuery;
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.http.DHttpException;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.platform.utils.exception.IErrorCode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SvcItemQuery 的远程实现(V2.0 拆分后的唯一实现):经 {@link SvcHallClient} 调办事大厅
|
||||||
|
* /oapi/svc/** 开放接口,把出参映射回原门户 VO——模板契约(docs/10 §3.4)零变化的保证。
|
||||||
|
* 缓存纪律:portalFilters/portalItem 维持原 @Cacheable 键(TTL 过期,无跨应用事件失效——
|
||||||
|
* 办事大厅变更后 CMS 最多滞后 5min);portalPage 实时调用。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class SvcItemRemoteQuery implements SvcItemQuery {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
|
private final SvcHallClient client;
|
||||||
|
private final CmsSiteRepository siteRepository;
|
||||||
|
|
||||||
|
public SvcItemRemoteQuery(SvcHallClient client, CmsSiteRepository siteRepository) {
|
||||||
|
this.client = client;
|
||||||
|
this.siteRepository = siteRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PortalServiceListData portalPage(SvcItemPortalQuery query) {
|
||||||
|
String siteCode = siteCodeOf(query.getSiteId());
|
||||||
|
JsonNode data = client.items(siteCode, query.getObjectKey(), query.getCategoryKey(),
|
||||||
|
query.getDeptGroupId(), query.getKeyword(), query.getPage(), query.getPageSize());
|
||||||
|
PortalServiceListData out = new PortalServiceListData();
|
||||||
|
out.setSiteId(query.getSiteId());
|
||||||
|
out.setTotal(longVal(data, "total"));
|
||||||
|
out.setPage(intVal(data, "page"));
|
||||||
|
out.setPageSize(intVal(data, "pageSize"));
|
||||||
|
out.setTotalPages(intVal(data, "totalPages"));
|
||||||
|
JsonNode items = data.get("items");
|
||||||
|
if (items != null && items.isArray()) {
|
||||||
|
for (JsonNode row : items) {
|
||||||
|
PortalServiceListData.Item item = new PortalServiceListData.Item();
|
||||||
|
item.setId(intVal(row, "id"));
|
||||||
|
item.setItemCode(text(row, "itemCode"));
|
||||||
|
item.setTitle(text(row, "title"));
|
||||||
|
item.setSummary(text(row, "summary"));
|
||||||
|
item.setCoverImage(text(row, "coverImage"));
|
||||||
|
item.setDeptName(text(row, "deptName"));
|
||||||
|
item.setCategoryName(text(row, "categoryName"));
|
||||||
|
item.setHandleMode(intVal(row, "handleMode"));
|
||||||
|
item.setOnlineFlag(intVal(row, "onlineFlag"));
|
||||||
|
item.setPublishTime(time(row, "publishTime"));
|
||||||
|
out.getItems().add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PortalServiceFilters portalFilters(Integer siteId) {
|
||||||
|
String siteCode = siteCodeOf(siteId);
|
||||||
|
JsonNode data = client.filters(siteCode);
|
||||||
|
PortalServiceFilters out = new PortalServiceFilters();
|
||||||
|
for (JsonNode row : nodes(data, "objects")) {
|
||||||
|
out.getObjects().add(new PortalServiceFilters.Option(
|
||||||
|
text(row, "key"), text(row, "name"), longVal(row, "count")));
|
||||||
|
}
|
||||||
|
for (JsonNode row : nodes(data, "categories")) {
|
||||||
|
out.getCategories().add(new PortalServiceFilters.Option(
|
||||||
|
text(row, "key"), text(row, "name"), longVal(row, "count")));
|
||||||
|
}
|
||||||
|
for (JsonNode row : nodes(data, "depts")) {
|
||||||
|
out.getDepts().add(new PortalServiceFilters.DeptOption(
|
||||||
|
intVal(row, "id"), text(row, "name"), longVal(row, "count")));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PortalServiceItem portalItem(Integer itemId, Integer siteId) {
|
||||||
|
String siteCode = siteCodeOf(siteId);
|
||||||
|
JsonNode row;
|
||||||
|
try {
|
||||||
|
row = client.detail(siteCode, itemId, null);
|
||||||
|
} catch (BizException e) {
|
||||||
|
// 兼容分支:服务内直抛 BizException 的旧口径(本链路实际走 DHttpException)
|
||||||
|
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
|
||||||
|
return null;
|
||||||
|
} catch (DHttpException e) {
|
||||||
|
// BP-R1-2:IDHttp 对传输失败与业务码非 0 一律抛 DHttpException(code=0 为传输类,
|
||||||
|
// 携带业务码为业务类)——原 catch(BizException) 是死代码。业务 code=2
|
||||||
|
// (不存在/未发布)→ 门户 404 语义(null 哨兵);传输/鉴权类失败上抛
|
||||||
|
// (P1-4 停机降级由 PortalController 统一承接,不混入 404)。
|
||||||
|
if (e.getCode() == 2) {
|
||||||
|
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// 目录路径(YschOpenCall)下的同义语义:业务失败信封 code!=0 → DException(携带
|
||||||
|
// OpenApiErrorCode,办事大厅"不存在/未发布"= 2);传输/HTTP 失败 → OpenApiException。
|
||||||
|
// 降级口径与 legacy 分支逐字一致,见上方注释。
|
||||||
|
if (businessAbsent(e)) {
|
||||||
|
log.info("[cms] 办事大厅详情未取到 itemId={}:{}", itemId, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return toPortalItem(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PortalServiceItem> relatedItems(Integer siteId, Integer itemId, String categoryKey, int limit) {
|
||||||
|
List<PortalServiceItem> result = new ArrayList<>();
|
||||||
|
if (categoryKey == null || categoryKey.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
JsonNode row;
|
||||||
|
try {
|
||||||
|
row = client.related(itemId, limit);
|
||||||
|
} catch (BizException e) {
|
||||||
|
return result;
|
||||||
|
} catch (DHttpException e) {
|
||||||
|
// 业务性缺失(code=2)→ 相关事项空;传输类失败上抛(停机降级口径见 portalItem 注释)
|
||||||
|
if (e.getCode() == 2) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (businessAbsent(e)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (JsonNode rel : row.isArray() ? row : new ArrayList<JsonNode>()) {
|
||||||
|
PortalServiceItem vo = new PortalServiceItem();
|
||||||
|
vo.setId(intVal(rel, "id"));
|
||||||
|
vo.setItemCode(text(rel, "itemCode"));
|
||||||
|
vo.setTitle(text(rel, "title"));
|
||||||
|
vo.setDeptName(text(rel, "deptName"));
|
||||||
|
vo.setHandleMode(intVal(rel, "handleMode"));
|
||||||
|
vo.setCategoryKey(categoryKey);
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SvcItemAttachmentVo> attachments(Integer itemId) {
|
||||||
|
List<SvcItemAttachmentVo> result = new ArrayList<>();
|
||||||
|
JsonNode row;
|
||||||
|
try {
|
||||||
|
row = client.attachments(itemId);
|
||||||
|
} catch (BizException e) {
|
||||||
|
return result;
|
||||||
|
} catch (DHttpException e) {
|
||||||
|
// 业务性缺失(code=2)→ 附件空;传输类失败上抛(停机降级口径见 portalItem 注释)
|
||||||
|
if (e.getCode() == 2) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (businessAbsent(e)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
for (JsonNode att : row.isArray() ? row : new ArrayList<JsonNode>()) {
|
||||||
|
SvcItemAttachmentVo vo = new SvcItemAttachmentVo();
|
||||||
|
vo.setId(intVal(att, "id"));
|
||||||
|
vo.setFileName(text(att, "fileName"));
|
||||||
|
vo.setWebUrl(text(att, "fileUrl"));
|
||||||
|
vo.setFileSize(longVal(att, "fileSize"));
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void countView(Integer itemId) {
|
||||||
|
// fire-and-forget:itemId 直取口径(办事大厅侧自行定位),客户端内部失败仅 warn
|
||||||
|
client.viewByItemId(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 装配 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "业务性缺失"判定(办事大厅 code=2 = 不存在/未发布)。两条出站路径的异常载体不同,
|
||||||
|
* 语义必须一致:legacy {@code IDHttp} → {@link DHttpException#getCode()} 为 int;
|
||||||
|
* 服务目录 {@code YschOpenCall} → {@link DException} 携带 {@code OpenApiErrorCode}。
|
||||||
|
*/
|
||||||
|
private static boolean businessAbsent(RuntimeException e) {
|
||||||
|
if (e instanceof DHttpException) {
|
||||||
|
return ((DHttpException) e).getCode() == 2;
|
||||||
|
}
|
||||||
|
if (e instanceof DException) {
|
||||||
|
IErrorCode code = ((DException) e).getCode();
|
||||||
|
return code != null && code.getCode() == 2;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PortalServiceItem toPortalItem(JsonNode row) {
|
||||||
|
PortalServiceItem vo = new PortalServiceItem();
|
||||||
|
vo.setId(intVal(row, "id"));
|
||||||
|
vo.setItemCode(text(row, "itemCode"));
|
||||||
|
vo.setTitle(text(row, "title"));
|
||||||
|
vo.setSummary(text(row, "summary"));
|
||||||
|
vo.setObjectKey(text(row, "objectKey"));
|
||||||
|
vo.setObjectName(text(row, "objectName"));
|
||||||
|
vo.setCategoryKey(text(row, "categoryKey"));
|
||||||
|
vo.setCategoryName(text(row, "categoryName"));
|
||||||
|
vo.setDeptGroupId(intVal(row, "deptGroupId"));
|
||||||
|
vo.setDeptName(text(row, "deptName"));
|
||||||
|
vo.setHandleMode(intVal(row, "handleMode"));
|
||||||
|
vo.setOnlineFlag(intVal(row, "onlineFlag"));
|
||||||
|
vo.setHandlePlace(text(row, "handlePlace"));
|
||||||
|
vo.setHandleTimeLimit(text(row, "handleTimeLimit"));
|
||||||
|
vo.setPromiseTimeLimit(text(row, "promiseTimeLimit"));
|
||||||
|
vo.setContactPhone(text(row, "contactPhone"));
|
||||||
|
vo.setSupervisePhone(text(row, "supervisePhone"));
|
||||||
|
vo.setProcessHtml(text(row, "processHtml"));
|
||||||
|
vo.setMaterialHtml(text(row, "materialHtml"));
|
||||||
|
vo.setNoticeHtml(text(row, "noticeHtml"));
|
||||||
|
vo.setFaqCategoryId(intVal(row, "faqCategoryId"));
|
||||||
|
vo.setNoticeCategoryId(intVal(row, "noticeCategoryId"));
|
||||||
|
vo.setRuleCategoryId(intVal(row, "ruleCategoryId"));
|
||||||
|
vo.setCoverImage(text(row, "coverImage"));
|
||||||
|
vo.setPublishTime(time(row, "publishTime"));
|
||||||
|
vo.setViewCount(intVal(row, "viewCount"));
|
||||||
|
vo.setApplyUrl(text(row, "applyUrl"));
|
||||||
|
vo.setFlowGuid(text(row, "flowGuid"));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String siteCodeOf(Integer siteId) {
|
||||||
|
return siteRepository.findById(siteId)
|
||||||
|
.map(CmsSite::getCode)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("站点不存在:" + siteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Iterable<JsonNode> nodes(JsonNode data, String field) {
|
||||||
|
JsonNode arr = data == null ? null : data.get(field);
|
||||||
|
return arr != null && arr.isArray() ? arr : new ArrayList<JsonNode>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode node, String field) {
|
||||||
|
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer intVal(JsonNode node, String field) {
|
||||||
|
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asInt() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long longVal(JsonNode node, String field) {
|
||||||
|
return node != null && node.has(field) && node.get(field).isNumber() ? node.get(field).asLong() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime time(JsonNode node, String field) {
|
||||||
|
String v = text(node, field);
|
||||||
|
if (v == null || v.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return LocalDateTime.parse(v, TIME);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ public class ThemeInfo {
|
|||||||
|
|
||||||
private List<ThemeParam> params = new ArrayList<>();
|
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() {
|
public String screenshotPath() {
|
||||||
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
|
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
package com.yunhai.cms.theme;
|
||||||
|
|
||||||
|
import com.yunhai.cms.entity.CmsSite;
|
||||||
|
import com.yunhai.cms.event.SiteContentChangedEvent;
|
||||||
|
import com.yunhai.cms.repository.CmsSiteRepository;
|
||||||
|
import com.yunhai.platform.apistd.BizException;
|
||||||
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
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;
|
||||||
|
import java.util.zip.ZipInputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 皮肤包完整管理(W1,方案 §5.4 步骤 2,D1:内置只读、外部可管):
|
||||||
|
* 上传(zip 防御:条目数≤2000/单文件≤10MB/总≤20MB/zip-slip/扩展名白名单)→
|
||||||
|
* 解包临时目录 → 复用 Registry 校验语义(ThemePackageValidator)产出报告式错误 →
|
||||||
|
* 原子 move 到 {cms.theme.dir}/{name} → registry.reload();
|
||||||
|
* 派生(复制包体+改名)、编辑(仅重写 theme.json 的 title/params,保留 specVersion 等)、
|
||||||
|
* 删除(被用站点拒绝且提示站点名;内置不可删);写操作后发布 SiteContentChangedEvent(缓存失效)。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class ThemePackageService {
|
||||||
|
|
||||||
|
private static final long MAX_TOTAL_BYTES = 20L * 1024 * 1024;
|
||||||
|
private static final long MAX_ENTRY_BYTES = 10L * 1024 * 1024;
|
||||||
|
private static final int MAX_ENTRIES = 2000;
|
||||||
|
private static final Pattern SAFE_ENTRY = Pattern.compile("^[A-Za-z0-9_\\-./]{1,200}$");
|
||||||
|
private static final Set<String> ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||||
|
".html", ".css", ".js", ".json", ".txt", ".ico", ".svg", ".png", ".jpg", ".jpeg",
|
||||||
|
".gif", ".webp", ".woff", ".woff2", ".ttf"));
|
||||||
|
|
||||||
|
private final ThemeRegistry themeRegistry;
|
||||||
|
private final CmsSiteRepository siteRepository;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
|
private String themeDir;
|
||||||
|
|
||||||
|
public ThemePackageService(ThemeRegistry themeRegistry, CmsSiteRepository siteRepository,
|
||||||
|
ApplicationEventPublisher eventPublisher) {
|
||||||
|
this.themeRegistry = themeRegistry;
|
||||||
|
this.siteRepository = siteRepository;
|
||||||
|
this.eventPublisher = eventPublisher;
|
||||||
|
this.themeDir = System.getProperty("user.dir") + "/data/themes";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void applyRegistryDir(ThemeRegistry registry) {
|
||||||
|
this.themeDir = registry.getThemeDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThemeDir(String dir) {
|
||||||
|
this.themeDir = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 上传(T-W1-05..12)=====
|
||||||
|
|
||||||
|
public ThemeInfo upload(BoaUser user, MultipartFile file, String title, String description) {
|
||||||
|
requireSuperAdmin(user);
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_TOTAL_BYTES) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包超过 20MB 上限(" + file.getSize() + " 字节)");
|
||||||
|
}
|
||||||
|
Path staging = null;
|
||||||
|
try {
|
||||||
|
staging = Files.createTempDirectory(Paths.get(themeDir), ".upload-");
|
||||||
|
List<String> report = new ArrayList<>();
|
||||||
|
String pkgName = extractZip(file, staging, report);
|
||||||
|
if (!report.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report));
|
||||||
|
}
|
||||||
|
if (pkgName == null) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:缺 theme.json");
|
||||||
|
}
|
||||||
|
String rootDir = singleRootDirName(staging);
|
||||||
|
if (rootDir != null && !rootDir.equals(pkgName)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"皮肤包校验未通过:name 与目录名不一致:" + rootDir + " != " + pkgName);
|
||||||
|
}
|
||||||
|
if (themeRegistry.contains(pkgName)
|
||||||
|
&& ThemeRegistry.SOURCE_BUILTIN.equals(themeRegistry.sourceOf(pkgName))) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"皮肤包与内置包同名(" + pkgName + "),禁止覆盖内置包");
|
||||||
|
}
|
||||||
|
// 包内校验(复用 Registry 语义 → 报告式错误,T-W1-08/09/10)
|
||||||
|
validateStagedPackage(rootDir == null ? staging : staging.resolve(rootDir), report);
|
||||||
|
if (!report.isEmpty()) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report));
|
||||||
|
}
|
||||||
|
// 上传表单命名(任务四):落位前改写 staging 内 theme.json,失败即整体失败不落位
|
||||||
|
applyStagedNaming(rootDir == null ? staging : staging.resolve(rootDir), title, description);
|
||||||
|
// 原子替换/落位(同名外部包=覆盖更新,T-W1-12)
|
||||||
|
Path target = Paths.get(themeDir, pkgName);
|
||||||
|
if (Files.exists(target)) {
|
||||||
|
Path old = Files.createTempDirectory(Paths.get(themeDir), ".old-");
|
||||||
|
moveDir(target, old.resolve("pkg"));
|
||||||
|
}
|
||||||
|
moveDir(rootDir == null ? staging : staging.resolve(rootDir), target);
|
||||||
|
themeRegistry.reload();
|
||||||
|
publishThemeChanged();
|
||||||
|
log.info("皮肤包 {} 上传注册成功(外部包)", pkgName);
|
||||||
|
return themeRegistry.get(pkgName);
|
||||||
|
} catch (BizException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw wrapFailure("上传", "皮肤包处理失败:", e);
|
||||||
|
} finally {
|
||||||
|
if (staging != null) {
|
||||||
|
deleteRecursively(staging);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** zip 解包后:仅当 staging 下恰有一个目录且无散落文件时返回该目录名(包根目录),否则 null */
|
||||||
|
private static String singleRootDirName(Path staging) throws IOException {
|
||||||
|
List<Path> entries = new ArrayList<>();
|
||||||
|
try (java.util.stream.Stream<Path> children = Files.list(staging)) {
|
||||||
|
children.forEach(entries::add);
|
||||||
|
}
|
||||||
|
if (entries.size() == 1 && Files.isDirectory(entries.get(0))) {
|
||||||
|
return entries.get(0).getFileName().toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传表单命名(任务四):落位前改写 staging 内 theme.json 的 title/description(空值跳过对应字段) */
|
||||||
|
private void applyStagedNaming(Path pkgDir, String title, String description) throws IOException {
|
||||||
|
if (!StringUtils.hasText(title) && description == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path json = pkgDir.resolve("theme.json");
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
|
||||||
|
if (StringUtils.hasText(title)) {
|
||||||
|
root.put("title", title.trim());
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
root.put("description", description);
|
||||||
|
}
|
||||||
|
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解包到 staging;返回 theme.json 声明的 name(防御:zip-slip/条目数/体积/扩展名白名单) */
|
||||||
|
private String extractZip(MultipartFile file, Path staging, List<String> report) throws IOException { String declaredName = null;
|
||||||
|
int entries = 0;
|
||||||
|
long total = 0;
|
||||||
|
try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) {
|
||||||
|
ZipEntry entry;
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
while ((entry = zip.getNextEntry()) != null) {
|
||||||
|
if (++entries > MAX_ENTRIES) {
|
||||||
|
report.add("zip 条目数超过 " + MAX_ENTRIES);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String name = entry.getName().replace('\\', '/');
|
||||||
|
if (name.contains("..") || !SAFE_ENTRY.matcher(name).matches()) {
|
||||||
|
report.add("zip 条目含非法路径:" + name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String ext = name.lastIndexOf('.') >= 0
|
||||||
|
? name.substring(name.lastIndexOf('.')).toLowerCase() : "";
|
||||||
|
if (!ALLOWED_EXTENSIONS.contains(ext)) {
|
||||||
|
report.add("zip 条目扩展名不允许:" + name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path out = staging.resolve(name).normalize();
|
||||||
|
if (!out.startsWith(staging)) {
|
||||||
|
report.add("zip 条目含非法路径(越界):" + name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Files.createDirectories(out.getParent());
|
||||||
|
long written = 0;
|
||||||
|
try (java.io.OutputStream os = Files.newOutputStream(out)) {
|
||||||
|
int n;
|
||||||
|
while ((n = zip.read(buffer)) > 0) {
|
||||||
|
written += n;
|
||||||
|
total += n;
|
||||||
|
if (written > MAX_ENTRY_BYTES) {
|
||||||
|
report.add("单文件超过 10MB:" + name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (total > MAX_TOTAL_BYTES) {
|
||||||
|
report.add("皮肤包超过 20MB 上限");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
os.write(buffer, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("theme.json".equals(name) || name.endsWith("/theme.json") && name.indexOf('/') == name.length() - 11) {
|
||||||
|
String json = new String(Files.readAllBytes(out), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
try {
|
||||||
|
declaredName = new com.fasterxml.jackson.databind.ObjectMapper()
|
||||||
|
.readTree(json).path("name").asText(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("zip 内 theme.json 解析失败,忽略声明包名:{}", e.getMessage());
|
||||||
|
declaredName = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return declaredName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 包内校验(与 Registry 同一 ThemePackageValidator 语义 → 逐条报告) */
|
||||||
|
private void validateStagedPackage(Path pkgDir, List<String> report) {
|
||||||
|
Path json = pkgDir.resolve("theme.json");
|
||||||
|
if (!Files.isRegularFile(json)) {
|
||||||
|
report.add("缺 theme.json");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper()
|
||||||
|
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
|
||||||
|
false);
|
||||||
|
ThemeInfo info = mapper.readValue(json.toFile(), ThemeInfo.class);
|
||||||
|
if (info.getName() == null) {
|
||||||
|
report.add("缺 theme.json(name 为空)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ThemeRegistry.isValidName(info.getName())) {
|
||||||
|
report.add("name 不符合命名规范:" + info.getName());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!info.getName().equals(pkgDir.getFileName().toString())) {
|
||||||
|
report.add("name 与目录名不一致:" + pkgDir.getFileName() + " != " + info.getName());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String template : ThemeRegistry.requiredTemplates()) {
|
||||||
|
if (!Files.exists(pkgDir.resolve(template))) {
|
||||||
|
report.add("缺必备模板 " + template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Files.exists(pkgDir.resolve("fragments").resolve("head.html"))) {
|
||||||
|
report.add("缺 fragments/head.html 公共片段");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ThemePackageValidator.validateParams(info);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
report.add(e.getMessage());
|
||||||
|
}
|
||||||
|
if (!Files.exists(pkgDir.resolve(info.screenshotPath()))) {
|
||||||
|
report.add("声明的 screenshot 不存在:" + info.screenshotPath());
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
report.add("theme.json 不是合法 JSON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 派生(T-W1-17)=====
|
||||||
|
|
||||||
|
public ThemeInfo derive(BoaUser user, String sourceName, String newName, String title, String description) {
|
||||||
|
requireSuperAdmin(user);
|
||||||
|
if (!themeRegistry.contains(sourceName)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + sourceName);
|
||||||
|
}
|
||||||
|
if (!ThemeRegistry.isValidName(newName)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "新皮肤包名不符合命名规范:" + newName);
|
||||||
|
}
|
||||||
|
if (themeRegistry.contains(newName)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包已存在:" + newName);
|
||||||
|
}
|
||||||
|
Path target = Paths.get(themeDir, newName);
|
||||||
|
try {
|
||||||
|
Files.createDirectories(target);
|
||||||
|
if (ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(sourceName))) {
|
||||||
|
copyDir(Paths.get(themeDir, sourceName), target);
|
||||||
|
} else {
|
||||||
|
copyClasspathPackage(sourceName, target);
|
||||||
|
}
|
||||||
|
// 改写 theme.json(任务四「添加皮肤包」表单):name=newName;显示名/描述按表单覆盖。
|
||||||
|
// ObjectNode 写回取代旧 regex replaceAll——源包名出现在其他字段值(如描述)时不再误替换
|
||||||
|
Path json = target.resolve("theme.json");
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
|
||||||
|
root.put("name", newName);
|
||||||
|
if (StringUtils.hasText(title)) {
|
||||||
|
root.put("title", title.trim());
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
root.put("description", description);
|
||||||
|
}
|
||||||
|
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||||
|
copyMetaParamValues(sourceName, newName);
|
||||||
|
themeRegistry.reload();
|
||||||
|
publishThemeChanged();
|
||||||
|
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
|
||||||
|
return themeRegistry.get(newName);
|
||||||
|
} catch (BizException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
deleteRecursively(target);
|
||||||
|
throw wrapFailure("派生", "派生皮肤包失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 派生时复制源包的 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();
|
||||||
|
org.springframework.core.io.Resource[] resources =
|
||||||
|
resolver.getResources("classpath*:templates/themes/" + sourceName + "/**");
|
||||||
|
for (org.springframework.core.io.Resource r : resources) {
|
||||||
|
String url = r.getURL().toString().replace('\\', '/');
|
||||||
|
int root = url.indexOf("templates/themes/" + sourceName + "/");
|
||||||
|
if (root < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String rel = url.substring(root + ("templates/themes/" + sourceName + "/").length());
|
||||||
|
if (rel.isEmpty() || url.endsWith("/")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Path out = target.resolve(rel);
|
||||||
|
Files.createDirectories(out.getParent());
|
||||||
|
try (java.io.InputStream in = r.getInputStream()) {
|
||||||
|
Files.copy(in, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 兜底:theme.json 未被 ** 命中时显式拷贝
|
||||||
|
if (!Files.exists(target.resolve("theme.json"))) {
|
||||||
|
try (java.io.InputStream in = new ClassPathResource(
|
||||||
|
"templates/themes/" + sourceName + "/theme.json").getInputStream()) {
|
||||||
|
Files.copy(in, target.resolve("theme.json"),
|
||||||
|
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 编辑(T-W1-17 + 任务四:title/描述/参数 schema;保留 specVersion/模板清单字段)=====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据编辑(任务四放开内置包):外部包直写 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);
|
||||||
|
}
|
||||||
|
boolean external = ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name));
|
||||||
|
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");
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
|
||||||
|
if (StringUtils.hasText(title)) {
|
||||||
|
root.put("title", title);
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
root.put("description", description);
|
||||||
|
}
|
||||||
|
if (params != null) {
|
||||||
|
ThemeInfo probe = new ThemeInfo();
|
||||||
|
probe.setParams(params);
|
||||||
|
try {
|
||||||
|
ThemePackageValidator.validateParams(probe);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage());
|
||||||
|
}
|
||||||
|
root.set("params", mapper.valueToTree(params));
|
||||||
|
}
|
||||||
|
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
|
||||||
|
if (filteredValues != null) {
|
||||||
|
writeMetaOverride(name, null, null, filteredValues);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
writeMetaOverride(name, title, description, filteredValues);
|
||||||
|
}
|
||||||
|
themeRegistry.reload();
|
||||||
|
publishThemeChanged();
|
||||||
|
return themeRegistry.get(name);
|
||||||
|
} catch (BizException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw wrapFailure("元数据写入", "theme.json 写入失败:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
|
||||||
|
* 三者全空且未提交 paramValues(即任务四的"恢复默认"手势)→ 删除覆盖层;
|
||||||
|
* 显式提交空 paramValues 只摘掉 paramValues 节点,保留覆盖层里既有的 title/description。
|
||||||
|
*/
|
||||||
|
private void writeMetaOverride(String name, String title, String description,
|
||||||
|
Map<String, String> paramValues) throws IOException {
|
||||||
|
Path overlay = Paths.get(themeDir, ".meta", name + ".json");
|
||||||
|
boolean noTitle = !StringUtils.hasText(title);
|
||||||
|
boolean noDesc = description == null || description.trim().isEmpty();
|
||||||
|
if (noTitle && noDesc && paramValues == null) {
|
||||||
|
try {
|
||||||
|
Files.deleteIfExists(overlay);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("元数据覆盖层 {} 删除失败:{}", overlay, e.getMessage());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode root;
|
||||||
|
if (Files.exists(overlay)) {
|
||||||
|
try {
|
||||||
|
root = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(overlay.toFile());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("元数据覆盖层 {} 损坏,重建:{}", overlay, e.getMessage());
|
||||||
|
root = mapper.createObjectNode();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root = mapper.createObjectNode();
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(title)) {
|
||||||
|
root.put("title", title.trim());
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 删除(T-W1-15/16)=====
|
||||||
|
|
||||||
|
public void remove(BoaUser user, String name) {
|
||||||
|
requireSuperAdmin(user);
|
||||||
|
if (!themeRegistry.contains(name)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
|
||||||
|
}
|
||||||
|
if (!ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name))) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "内置包不可删除(D1:内置只读)");
|
||||||
|
}
|
||||||
|
List<CmsSite> inUse = siteRepository.findByTheme(name);
|
||||||
|
if (!inUse.isEmpty()) {
|
||||||
|
List<String> names = new ArrayList<>();
|
||||||
|
for (CmsSite s : inUse) {
|
||||||
|
names.add(s.getName());
|
||||||
|
}
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"皮肤包正被站点使用,无法删除:" + String.join("、", names));
|
||||||
|
}
|
||||||
|
deleteRecursively(Paths.get(themeDir, name));
|
||||||
|
// 元数据覆盖层一并清理(任务四;外部包通常没有,best-effort)
|
||||||
|
deleteRecursively(Paths.get(themeDir, ".meta", name + ".json"));
|
||||||
|
themeRegistry.reload();
|
||||||
|
publishThemeChanged();
|
||||||
|
log.info("皮肤包 {} 已删除并热注销", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 热重载(T-W1-02/18)=====
|
||||||
|
|
||||||
|
public void reload(BoaUser user) {
|
||||||
|
requireSuperAdmin(user);
|
||||||
|
themeRegistry.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 内部 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底异常包装(C 级修复):先以 error 级记录原始堆栈再转业务异常——
|
||||||
|
* 线上(/app 容器)只能看到接口文案,没有这一步则 FileSystemException 等
|
||||||
|
* 只剩裸路径无从排查。userMessage 保持既有文案格式(含冒号后缀)。
|
||||||
|
*/
|
||||||
|
static BizException wrapFailure(String logAction, String userMessage, Exception e) {
|
||||||
|
log.error("皮肤包{}失败", logAction, e);
|
||||||
|
return new BizException(ErrorCode.PARAM_ERROR, userMessage + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireSuperAdmin(BoaUser user) {
|
||||||
|
if (user == null || user.getType() == null || user.getType() != 1) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用站点缓存失效(SiteService.java:159 同款事件,PortalCacheEvictListener 整缓存 clear) */
|
||||||
|
private void publishThemeChanged() {
|
||||||
|
eventPublisher.publishEvent(new SiteContentChangedEvent(null, "theme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void moveDir(Path from, Path to) throws IOException {
|
||||||
|
Files.createDirectories(to.getParent());
|
||||||
|
try (java.util.stream.Stream<Path> walk = Files.walk(from)) {
|
||||||
|
walk.sorted(Comparator.naturalOrder()).forEach(p -> {
|
||||||
|
try {
|
||||||
|
Path dst = to.resolve(from.relativize(p).toString());
|
||||||
|
if (Files.isDirectory(p)) {
|
||||||
|
Files.createDirectories(dst);
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(dst.getParent());
|
||||||
|
Files.move(p, dst);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deleteRecursively(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copyDir(Path from, Path to) throws IOException {
|
||||||
|
try (java.util.stream.Stream<Path> walk = Files.walk(from)) {
|
||||||
|
walk.forEach(p -> {
|
||||||
|
try {
|
||||||
|
Path dst = to.resolve(from.relativize(p).toString());
|
||||||
|
if (Files.isDirectory(p)) {
|
||||||
|
Files.createDirectories(dst);
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(dst.getParent());
|
||||||
|
Files.copy(p, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deleteRecursively(Path path) {
|
||||||
|
if (path == null || !Files.exists(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (java.util.stream.Stream<Path> walk = Files.walk(path)) {
|
||||||
|
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||||
|
try {
|
||||||
|
Files.delete(p);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("清理失败(best-effort):{}", p, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("清理失败(best-effort):{}", path, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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、首页区块引用一致性(§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,
|
||||||
|
Function<String, String> readFn) {
|
||||||
|
if (info == null) {
|
||||||
|
throw new IllegalArgumentException("清单为空");
|
||||||
|
}
|
||||||
|
if (info.getSpecVersion() == null || info.getSpecVersion() != 1) {
|
||||||
|
throw new IllegalArgumentException("specVersion 必须为 1");
|
||||||
|
}
|
||||||
|
if (!ThemeRegistry.isValidName(info.getName())) {
|
||||||
|
throw new IllegalArgumentException("name 缺失或不符合命名规范:" + info.getName());
|
||||||
|
}
|
||||||
|
if (!info.getName().equals(dirName)) {
|
||||||
|
throw new IllegalArgumentException("name 与目录名不一致:" + dirName + " != " + info.getName());
|
||||||
|
}
|
||||||
|
for (String template : ThemeRegistry.requiredTemplates()) {
|
||||||
|
if (!existsFn.test(template)) {
|
||||||
|
throw new IllegalArgumentException("缺必备模板 " + template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fragments/ 目录存在性以 head.html 片段代替(jar 内目录项不可靠)
|
||||||
|
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)) {
|
||||||
|
throw new IllegalArgumentException("声明的 screenshot 不存在:" + shot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** param 规范(key/类型/重复/cssVar 仅 color/select 必带 options);不合法抛 IllegalArgumentException */
|
||||||
|
public static void validateParams(ThemeInfo info) {
|
||||||
|
List<ThemeParam> params = info.getParams();
|
||||||
|
List<String> seen = new java.util.ArrayList<>();
|
||||||
|
for (ThemeParam p : params) {
|
||||||
|
if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) {
|
||||||
|
throw new IllegalArgumentException("param key 非法:" + p.getKey());
|
||||||
|
}
|
||||||
|
if (seen.contains(p.getKey())) {
|
||||||
|
throw new IllegalArgumentException("param key 重复:" + p.getKey());
|
||||||
|
}
|
||||||
|
seen.add(p.getKey());
|
||||||
|
String type = p.getType();
|
||||||
|
boolean knownType = ThemeParam.TYPE_COLOR.equals(type) || ThemeParam.TYPE_IMAGE.equals(type)
|
||||||
|
|| ThemeParam.TYPE_SELECT.equals(type) || ThemeParam.TYPE_TEXT.equals(type);
|
||||||
|
if (!knownType) {
|
||||||
|
throw new IllegalArgumentException("param " + p.getKey() + " 类型非法:" + type);
|
||||||
|
}
|
||||||
|
if (p.getCssVar() != null && !p.getCssVar().isEmpty() && !ThemeParam.TYPE_COLOR.equals(type)) {
|
||||||
|
throw new IllegalArgumentException("cssVar 仅允许 color 类型参数:" + p.getKey());
|
||||||
|
}
|
||||||
|
if (ThemeParam.TYPE_SELECT.equals(type)
|
||||||
|
&& (p.getOptions() == null || p.getOptions().isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("select 参数缺 options:" + p.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页区块门禁(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +48,13 @@ public class ThemeParam {
|
|||||||
|
|
||||||
private List<Option> options;
|
private List<Option> options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/20 E(colorScheme 配色预设):select 参数可携带「选项值 → CSS 变量组」映射——
|
||||||
|
* 选中某选项时整组变量并入 :root{}(值逐一过 COLOR_PATTERN,非法丢弃)。
|
||||||
|
* 供伪主题合并:色皮档 4 套并 default、骨架克隆档 6 套并 scholar-blue。
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, String>> presets;
|
||||||
|
|
||||||
/** 值是否合法(按参数类型;null/空一律不合法,方案 §5.3) */
|
/** 值是否合法(按参数类型;null/空一律不合法,方案 §5.3) */
|
||||||
public boolean isValidValue(String value) {
|
public boolean isValidValue(String value) {
|
||||||
if (value == null || value.isEmpty()) {
|
if (value == null || value.isEmpty()) {
|
||||||
|
|||||||
@@ -3,31 +3,84 @@ package com.yunhai.cms.theme;
|
|||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StreamUtils;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import java.io.IOException;
|
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;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题注册表(方案 §5.1):启动时按 SCAN_PATTERN 扫描各主题包的 theme.json,
|
* 主题注册表(方案 §5.1;W1 扩展:外部主题目录 + 热注册,方案 §5.4 步骤 1):
|
||||||
|
* 启动时扫描 classpath 内置包与 {@code cms.theme.dir} 外部包(默认 ${user.dir}/data/themes),
|
||||||
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
|
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
|
||||||
* 运行时不支持热加载,皮肤包随版本发布(zip 上传是 P3 项)。
|
*
|
||||||
|
* <p>W1 语义(D1):内置只读、外部可管;内置优先于同名外部(冲突留 warn);{@link #reload()}
|
||||||
|
* 运行期热重扫(default 缺失时抛异常且不回滚旧集合,T-W1-04);校验语义收敛于
|
||||||
|
* {@link ThemePackageValidator}(Registry 与 PackageService 共用,杜绝两套校验漂移)。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class ThemeRegistry {
|
public class ThemeRegistry {
|
||||||
|
|
||||||
public static final String DEFAULT_THEME = "default";
|
public static final String DEFAULT_THEME = "default";
|
||||||
|
public static final String SOURCE_BUILTIN = "builtin";
|
||||||
|
public static final String SOURCE_EXTERNAL = "external";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/20 E(激进清场)下架主题别名表:原主题名 → [保留主题, colorScheme 预设值]。
|
||||||
|
* 存量站点 theme 仍写下架名时按本表解析(R-46:门户不断渲染,不 404);
|
||||||
|
* 站点下次保存时由 SiteService 归一化为保留主题 + config.theme_params.colorScheme。
|
||||||
|
*/
|
||||||
|
private static final java.util.Map<String, String[]> DEPRECATED_ALIASES = java.util.Collections.unmodifiableMap(
|
||||||
|
new java.util.LinkedHashMap<String, String[]>() {{
|
||||||
|
// 色皮档 4 套 → default 预设
|
||||||
|
put("blue-white", new String[] {DEFAULT_THEME, "blue-white"});
|
||||||
|
put("classic-red", new String[] {DEFAULT_THEME, "classic-red"});
|
||||||
|
put("fresh-mint", new String[] {DEFAULT_THEME, "fresh-mint"});
|
||||||
|
put("warm-kinder", new String[] {DEFAULT_THEME, "warm-kinder"});
|
||||||
|
// 骨架克隆档 6 套 → scholar-blue 预设
|
||||||
|
put("academic-navy", new String[] {"scholar-blue", "navy"});
|
||||||
|
put("campus-azure", new String[] {"scholar-blue", "azure"});
|
||||||
|
put("poly-blue", new String[] {"scholar-blue", "poly"});
|
||||||
|
put("agri-forest", new String[] {"scholar-blue", "agri"});
|
||||||
|
put("jade-normal", new String[] {"scholar-blue", "jade"});
|
||||||
|
put("mint-breeze", new String[] {"scholar-blue", "mint"});
|
||||||
|
}});
|
||||||
|
|
||||||
|
/** 下架别名解析结果:target=保留主题名;colorScheme=对应预设值(可能为 null) */
|
||||||
|
public static final class AliasInfo {
|
||||||
|
public final String target;
|
||||||
|
public final String colorScheme;
|
||||||
|
|
||||||
|
AliasInfo(String target, String colorScheme) {
|
||||||
|
this.target = target;
|
||||||
|
this.colorScheme = colorScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下架别名解析:非别名返回 null */
|
||||||
|
public static AliasInfo aliasOf(String name) {
|
||||||
|
String[] mapped = name == null ? null : DEPRECATED_ALIASES.get(name);
|
||||||
|
return mapped == null ? null : new AliasInfo(mapped[0], mapped[1]);
|
||||||
|
}
|
||||||
|
|
||||||
/** 包名与目录名约束(方案 §4.1) */
|
/** 包名与目录名约束(方案 §4.1) */
|
||||||
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$");
|
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$");
|
||||||
@@ -38,35 +91,130 @@ public class ThemeRegistry {
|
|||||||
|
|
||||||
private static final String SCAN_PATTERN = "classpath*:templates/themes/*/theme.json";
|
private static final String SCAN_PATTERN = "classpath*:templates/themes/*/theme.json";
|
||||||
|
|
||||||
|
/** 元数据覆盖层目录(任务四):{cms.theme.dir}/.meta/{name}.json;外部包扫描按「含 theme.json 的目录」过滤,天然跳过 */
|
||||||
|
private static final String META_OVERRIDE_DIR = ".meta";
|
||||||
|
|
||||||
/** 未知字段忽略、已知字段兼容(方案 §4.2 specVersion 演进策略) */
|
/** 未知字段忽略、已知字段兼容(方案 §4.2 specVersion 演进策略) */
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
private final Map<String, ThemeInfo> themes = new LinkedHashMap<>();
|
private final Map<String, ThemeInfo> themes = new LinkedHashMap<>();
|
||||||
|
private final Map<String, String> sources = new LinkedHashMap<>();
|
||||||
|
private final List<Runnable> reloadListeners = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 外部主题目录(W1;测试经 setter 指向临时目录) */
|
||||||
|
private String themeDir;
|
||||||
|
|
||||||
|
@Value("${cms.theme.dir:}")
|
||||||
|
public void applyConfiguredThemeDir(String dir) {
|
||||||
|
if (dir != null && !dir.trim().isEmpty()) {
|
||||||
|
this.themeDir = dir.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThemeRegistry() {
|
||||||
|
if (themeDir == null || themeDir.isEmpty()) {
|
||||||
|
themeDir = System.getProperty("user.dir") + "/data/themes";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setThemeDir(String dir) {
|
||||||
|
this.themeDir = dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThemeDir() {
|
||||||
|
return themeDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** reload 后清外部模板缓存(ThemeTemplateResolverConfig 注册回调) */
|
||||||
|
public void addReloadListener(Runnable listener) {
|
||||||
|
reloadListeners.add(listener);
|
||||||
|
}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void load() {
|
public void load() {
|
||||||
themes.clear();
|
reloadInternal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* W1 热重扫(T-W1-02/04):重扫 classpath + 外部目录;
|
||||||
|
* default 缺失或非法时抛 IllegalStateException 且旧集合保持不变。
|
||||||
|
*/
|
||||||
|
public synchronized void reload() {
|
||||||
|
reloadInternal(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reloadInternal(boolean startup) {
|
||||||
Map<String, ThemeInfo> loaded = new LinkedHashMap<>();
|
Map<String, ThemeInfo> loaded = new LinkedHashMap<>();
|
||||||
|
Map<String, String> loadedSources = new LinkedHashMap<>();
|
||||||
int rejected = 0;
|
int rejected = 0;
|
||||||
for (Resource resource : scanResources()) {
|
|
||||||
|
// ① 内置 classpath 包(先注册,优先级高)
|
||||||
|
for (Resource resource : scanClasspathResources()) {
|
||||||
String dirName = dirNameOf(resource);
|
String dirName = dirNameOf(resource);
|
||||||
try {
|
try {
|
||||||
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
|
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
|
||||||
validate(info, dirName);
|
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);
|
loaded.put(info.getName(), info);
|
||||||
|
loadedSources.put(info.getName(), SOURCE_BUILTIN);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
rejected++;
|
rejected++;
|
||||||
log.warn("主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
|
log.warn("主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ② 外部目录包(同名让位内置,warn 留痕;T-W1-03)
|
||||||
|
for (Path pkg : scanExternalPackages()) {
|
||||||
|
String dirName = pkg.getFileName().toString();
|
||||||
|
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)),
|
||||||
|
rel -> readTextFile(pkg.resolve(rel)));
|
||||||
|
if (loaded.containsKey(info.getName())) {
|
||||||
|
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
applyMetaOverride(info);
|
||||||
|
loaded.put(info.getName(), info);
|
||||||
|
loadedSources.put(info.getName(), SOURCE_EXTERNAL);
|
||||||
|
} catch (Exception e) {
|
||||||
|
rejected++;
|
||||||
|
log.warn("外部主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<ThemeInfo> sorted = new ArrayList<>(loaded.values());
|
List<ThemeInfo> sorted = new ArrayList<>(loaded.values());
|
||||||
sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName())
|
sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName())
|
||||||
? "" : info.getName()));
|
? "" : info.getName()));
|
||||||
|
Map<String, ThemeInfo> newThemes = new LinkedHashMap<>();
|
||||||
|
Map<String, String> newSources = new LinkedHashMap<>();
|
||||||
for (ThemeInfo info : sorted) {
|
for (ThemeInfo info : sorted) {
|
||||||
themes.put(info.getName(), info);
|
newThemes.put(info.getName(), info);
|
||||||
|
newSources.put(info.getName(), loadedSources.get(info.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newThemes.containsKey(DEFAULT_THEME)) {
|
||||||
|
if (startup) {
|
||||||
|
throw new IllegalStateException("默认主题包 default 缺失或非法,拒绝启动(方案 §5.1)");
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("默认主题包 default 缺失或非法,reload 已中止(旧集合保持不变,T-W1-04)");
|
||||||
|
}
|
||||||
|
themes.clear();
|
||||||
|
themes.putAll(newThemes);
|
||||||
|
sources.clear();
|
||||||
|
sources.putAll(newSources);
|
||||||
|
for (Runnable listener : reloadListeners) {
|
||||||
|
try {
|
||||||
|
listener.run();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("主题注册表 reload 回调失败:{}", e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
verifyDefaultPresent();
|
|
||||||
log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected);
|
log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,66 +238,56 @@ public class ThemeRegistry {
|
|||||||
return name == null ? null : themes.get(name);
|
return name == null ? null : themes.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 包规范校验(方案 §4.1)=====
|
/** W1:包来源(builtin/external);未注册返回 null */
|
||||||
|
public String sourceOf(String name) {
|
||||||
|
return name == null ? null : sources.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
private void validate(ThemeInfo info, String dirName) {
|
/** W1:包内相对资源定位(内置→ClassPathResource;外部→FileSystemResource,exists 即时判定) */
|
||||||
if (info == null) {
|
public Resource resource(String name, String relativePath) {
|
||||||
throw new IllegalArgumentException("清单为空");
|
String rel = relativePath == null ? "" : relativePath;
|
||||||
|
while (rel.startsWith("/")) {
|
||||||
|
rel = rel.substring(1);
|
||||||
}
|
}
|
||||||
if (info.getSpecVersion() == null || info.getSpecVersion() != 1) {
|
if (SOURCE_EXTERNAL.equals(sources.get(name))) {
|
||||||
throw new IllegalArgumentException("specVersion 必须为 1");
|
return new FileSystemResource(Paths.get(themeDir, name, rel).toFile());
|
||||||
}
|
}
|
||||||
if (info.getName() == null || !NAME_PATTERN.matcher(info.getName()).matches()) {
|
return new ClassPathResource("templates/themes/" + name + "/" + rel);
|
||||||
throw new IllegalArgumentException("name 缺失或不符合命名规范:" + info.getName());
|
|
||||||
}
|
}
|
||||||
if (!info.getName().equals(dirName)) {
|
|
||||||
throw new IllegalArgumentException("name 与目录名不一致:" + dirName + " != " + info.getName());
|
// ===== 包规范校验(§4.1,语义收敛于 ThemePackageValidator)=====
|
||||||
|
|
||||||
|
/** 包名规范(PackageService 上传/派生共用) */
|
||||||
|
public static boolean isValidName(String name) {
|
||||||
|
return name != null && NAME_PATTERN.matcher(name).matches();
|
||||||
}
|
}
|
||||||
for (String template : REQUIRED_TEMPLATES) {
|
|
||||||
if (!new ClassPathResource("templates/themes/" + dirName + "/" + template).exists()) {
|
public static List<String> requiredTemplates() {
|
||||||
throw new IllegalArgumentException("缺必备模板 " + template);
|
return REQUIRED_TEMPLATES;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// fragments/ 目录存在性以 head.html 片段代替(jar 内目录项不可靠)
|
/** 读 classpath 包内文本(UTF-8);读不到返回 null(区块门禁据此跳过,不臆断) */
|
||||||
if (!new ClassPathResource("templates/themes/" + dirName + "/fragments/head.html").exists()) {
|
private static String readClasspath(String location) {
|
||||||
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
|
try (InputStream in = new ClassPathResource(location).getInputStream()) {
|
||||||
}
|
return StreamUtils.copyToString(in, StandardCharsets.UTF_8);
|
||||||
validateParams(info);
|
} catch (IOException e) {
|
||||||
String shot = "templates/themes/" + dirName + "/" + info.screenshotPath();
|
return null;
|
||||||
if (!new ClassPathResource(shot).exists()) {
|
|
||||||
throw new IllegalArgumentException("声明的 screenshot 不存在:" + info.screenshotPath());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateParams(ThemeInfo info) {
|
/** 读外部包内文本(UTF-8);读不到返回 null */
|
||||||
List<String> seen = new ArrayList<>();
|
private static String readTextFile(Path file) {
|
||||||
for (ThemeParam p : info.getParams()) {
|
try {
|
||||||
if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) {
|
return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
|
||||||
throw new IllegalArgumentException("param key 非法:" + p.getKey());
|
} catch (IOException e) {
|
||||||
}
|
return null;
|
||||||
if (seen.contains(p.getKey())) {
|
|
||||||
throw new IllegalArgumentException("param key 重复:" + p.getKey());
|
|
||||||
}
|
|
||||||
seen.add(p.getKey());
|
|
||||||
String type = p.getType();
|
|
||||||
boolean knownType = ThemeParam.TYPE_COLOR.equals(type) || ThemeParam.TYPE_IMAGE.equals(type)
|
|
||||||
|| ThemeParam.TYPE_SELECT.equals(type) || ThemeParam.TYPE_TEXT.equals(type);
|
|
||||||
if (!knownType) {
|
|
||||||
throw new IllegalArgumentException("param " + p.getKey() + " 类型非法:" + type);
|
|
||||||
}
|
|
||||||
if (p.getCssVar() != null && !p.getCssVar().isEmpty() && !ThemeParam.TYPE_COLOR.equals(type)) {
|
|
||||||
throw new IllegalArgumentException("cssVar 仅允许 color 类型参数:" + p.getKey());
|
|
||||||
}
|
|
||||||
if (ThemeParam.TYPE_SELECT.equals(type)
|
|
||||||
&& (p.getOptions() == null || p.getOptions().isEmpty())) {
|
|
||||||
throw new IllegalArgumentException("select 参数缺 options:" + p.getKey());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 资源定位 =====
|
// ===== 资源定位 =====
|
||||||
|
|
||||||
private List<Resource> scanResources() {
|
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
|
||||||
|
protected List<Resource> scanClasspathResources() {
|
||||||
try {
|
try {
|
||||||
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
|
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
|
||||||
return Arrays.asList(resources);
|
return Arrays.asList(resources);
|
||||||
@@ -158,6 +296,66 @@ public class ThemeRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
|
||||||
|
* 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
|
||||||
|
*/
|
||||||
|
private void applyMetaOverride(ThemeInfo info) {
|
||||||
|
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path overrideFile = Paths.get(themeDir, META_OVERRIDE_DIR, info.getName() + ".json");
|
||||||
|
if (!Files.isRegularFile(overrideFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ThemeInfo meta = MAPPER.readValue(overrideFile.toFile(), ThemeInfo.class);
|
||||||
|
if (meta.getTitle() != null && !meta.getTitle().trim().isEmpty()) {
|
||||||
|
info.setTitle(meta.getTitle().trim());
|
||||||
|
}
|
||||||
|
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()) {
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
Path root = Paths.get(themeDir);
|
||||||
|
if (!Files.isDirectory(root)) {
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
try (java.util.stream.Stream<Path> children = Files.list(root)) {
|
||||||
|
children.filter(Files::isDirectory)
|
||||||
|
.filter(p -> Files.isRegularFile(p.resolve("theme.json")))
|
||||||
|
.sorted(Comparator.comparing(p -> p.getFileName().toString()))
|
||||||
|
.forEach(packages::add);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("外部主题目录扫描失败:{}({})", themeDir, e.getMessage());
|
||||||
|
}
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
/** 从 theme.json 资源 URL 截取所属包目录名 */
|
/** 从 theme.json 资源 URL 截取所属包目录名 */
|
||||||
private String dirNameOf(Resource resource) {
|
private String dirNameOf(Resource resource) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -23,15 +23,23 @@ public final class ThemeVarsCss {
|
|||||||
}
|
}
|
||||||
StringBuilder sb = new StringBuilder(":root{");
|
StringBuilder sb = new StringBuilder(":root{");
|
||||||
for (ThemeParam p : theme.getParams()) {
|
for (ThemeParam p : theme.getParams()) {
|
||||||
if (!p.isCssVarParam()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String value = params.get(p.getKey());
|
String value = params.get(p.getKey());
|
||||||
// 双重校验(方案 §9 注入面):入口已过滤,此处仍只拼通过 color 正则的值
|
|
||||||
if (value == null || !p.isValidValue(value)) {
|
if (value == null || !p.isValidValue(value)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (p.isCssVarParam()) {
|
||||||
|
// 双重校验(方案 §9 注入面):入口已过滤,此处仍只拼通过 color 正则的值
|
||||||
sb.append(p.getCssVar()).append(':').append(value).append(';');
|
sb.append(p.getCssVar()).append(':').append(value).append(';');
|
||||||
|
} else if (p.getPresets() != null && p.getPresets().containsKey(value)) {
|
||||||
|
// docs/20 E:select 参数携带 presets(colorScheme → 变量组)时,选中值对应的
|
||||||
|
// 整组变量并入输出(每个值仍过 COLOR_PATTERN,非法丢弃——注入面与 color 参数同标)
|
||||||
|
p.getPresets().get(value).forEach((cssVar, color) -> {
|
||||||
|
if (cssVar != null && cssVar.matches("^--[a-zA-Z][\\w-]*$")
|
||||||
|
&& color != null && ThemeParam.COLOR_PATTERN.matcher(color).matches()) {
|
||||||
|
sb.append(cssVar).append(':').append(color).append(';');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return sb.length() == 6 ? "" : sb.append('}').toString();
|
return sb.length() == 6 ? "" : sb.append('}').toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,12 @@ public class PortalUrlHelper {
|
|||||||
*/
|
*/
|
||||||
public static PortalUrlHelper forPreview(HttpServletRequest request, String siteCode, String theme) {
|
public static PortalUrlHelper forPreview(HttpServletRequest request, String siteCode, String theme) {
|
||||||
PortalUrlHelper base = fromRequest(request, siteCode, theme);
|
PortalUrlHelper base = fromRequest(request, siteCode, theme);
|
||||||
// 显式走 5 参构造:第 4 参是 base-origin,代理前缀必须落第 5 参(避免误绑 4 参重载)
|
// 显式走 5 参构造:第 4 参是 base-origin,代理前缀必须落第 5 参(避免误绑 4 参重载)。
|
||||||
return new PortalUrlHelper(base.baseUrl, siteCode, theme, null, "/api/theme/" + theme + "/asset/");
|
// W3(方案 §7.4):前缀拼 request.getContextPath()——base/prod 上下文 /yschcms 下
|
||||||
|
// 不拼会导致 iframe 内 CSS/JS 链接打到网关根 404(预览裸 HTML);
|
||||||
|
// 空上下文(dev)退化为根相对 /api/theme/…,dev 形态不回归(T-W3-02)。
|
||||||
|
return new PortalUrlHelper(base.baseUrl, siteCode, theme, null,
|
||||||
|
request.getContextPath() + "/api/theme/" + theme + "/asset/");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,6 +132,41 @@ public class PortalUrlHelper {
|
|||||||
return page != null && page > 1 ? url + "&page=" + page : url;
|
return page != null && page > 1 ? url + "&page=" + page : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务中心列表基址(事项库 §5.4:模板禁止手拼 query,筛选/分页 URL 一律后端预生成)。
|
||||||
|
*/
|
||||||
|
public String serviceList() {
|
||||||
|
return baseUrl + "/service";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务中心列表带筛选/分页参数:null 与空串值跳过,值按 UTF-8 编码(LinkedHashMap 保序) */
|
||||||
|
public String serviceList(java.util.Map<String, Object> query) {
|
||||||
|
if (query == null || query.isEmpty()) {
|
||||||
|
return serviceList();
|
||||||
|
}
|
||||||
|
StringBuilder qs = new StringBuilder();
|
||||||
|
for (java.util.Map.Entry<String, Object> entry : query.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
if (key == null || key.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String value = entry.getValue() == null ? "" : String.valueOf(entry.getValue());
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (qs.length() > 0) {
|
||||||
|
qs.append('&');
|
||||||
|
}
|
||||||
|
qs.append(urlEncode(key)).append('=').append(urlEncode(value));
|
||||||
|
}
|
||||||
|
return qs.length() == 0 ? serviceList() : serviceList() + "?" + qs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事项指南页 */
|
||||||
|
public String serviceItem(Object itemId) {
|
||||||
|
return baseUrl + "/sv/" + itemId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。
|
* 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。
|
||||||
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
|
* URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.yunhai.cms.utils;
|
||||||
|
|
||||||
|
import java.io.UnsupportedEncodingException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「我要办理」深链构造器(T4 回跳闭环,2026-10-03 执行方案)。
|
||||||
|
* 纯函数无状态:base 尾斜杠裁剪、参数 UTF-8 urlencode、from 缺省不追加。
|
||||||
|
*/
|
||||||
|
public final class SvcHallLinks {
|
||||||
|
|
||||||
|
private SvcHallLinks() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「我要办理」深链(双端契约:hall/src/router/routes.ts 依赖此形态,改参数名两侧同步)。
|
||||||
|
*
|
||||||
|
* @param base svc-hall hall 部署基地址(站点 svc_hall_base,尾斜杠容忍)
|
||||||
|
* @param siteCode 站点编码
|
||||||
|
* @param itemCode 事项编码
|
||||||
|
* @param fromUrl 指南页绝对 URL(回跳地址;null/空=不追加 from 段,hall 不显示返回门户)
|
||||||
|
*/
|
||||||
|
public static String build(String base, String siteCode, String itemCode, String fromUrl) {
|
||||||
|
String url = trimTail(base) + "/#/apply?siteCode=" + enc(siteCode)
|
||||||
|
+ "&itemCode=" + enc(itemCode);
|
||||||
|
return fromUrl == null || fromUrl.isEmpty() ? url : url + "&from=" + enc(fromUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTail(String url) {
|
||||||
|
String s = url == null ? "" : url.trim();
|
||||||
|
while (s.endsWith("/")) {
|
||||||
|
s = s.substring(0, s.length() - 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enc(String value) {
|
||||||
|
try {
|
||||||
|
return URLEncoder.encode(value, "UTF-8");
|
||||||
|
} catch (UnsupportedEncodingException e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,12 +60,44 @@ yh:
|
|||||||
# UserBase 注册后分配(进白名单),与 yh.open 保持一致;生产由 projman 注入
|
# UserBase 注册后分配(进白名单),与 yh.open 保持一致;生产由 projman 注入
|
||||||
app-key: 100005
|
app-key: 100005
|
||||||
app-secret: 2c7885de594742cc840b8762910b2d2e
|
app-secret: 2c7885de594742cc840b8762910b2d2e
|
||||||
|
# 提供方强制验签(两级模型 §4.1,flow-center/Notice 同款):本地 svcitem/config/
|
||||||
|
# OpenApiAuthEnforceConfiguration 副本已删(2026-10-05),换此一行——/oapi/**(站点目录
|
||||||
|
# /oapi/site/list)无签名三件套即 401(starter 版 RequireAppSignFilter,order +11 行为等价)
|
||||||
|
server:
|
||||||
|
enforce-patterns: [/oapi/*]
|
||||||
|
# 中央密钥链(两级模型 §4.2):trusted-apps 先(确定性兜底),未命中走 SignKeyResolver
|
||||||
|
# (Redis sign:app:* → Caffeine → UserBase 兜底端点)——任何在 UserBase 注册过的 appKey
|
||||||
|
# 自动可验。需 data-redis 编译期依赖(yh-app-auth 侧 optional 不传递,pom 已提升 scope)
|
||||||
|
trust-registry: true
|
||||||
|
# 入站验签白名单(组合链第一层,Redis 停摆时确定性工作):办事大厅 svc-hall
|
||||||
|
# (站点目录 /oapi/site/list 消费方,appKey=100018;P0-1 修复)。
|
||||||
|
# secret 与 svc-hall application.yml yh.app-auth.app-secret 逐字一致(联调态),
|
||||||
|
# 生产两侧由 projman/Nacos 注入同一密钥对
|
||||||
|
trusted-apps:
|
||||||
|
"100018": "svc-hall-dev-secret-100018"
|
||||||
open:
|
open:
|
||||||
enabled: true
|
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
||||||
base-host: http://base.yunhaixingtong.com
|
base-host: http://base.yunhaixingtong.com
|
||||||
base-path: /userbase
|
base-path: /userbase
|
||||||
app-key: 100005
|
# 服务目录(子应用配置优化方案 §4.2):跨服务出站唯一地址来源,命名目标走 SDK 门面 /
|
||||||
app-secret: 2c7885de594742cc840b8762910b2d2e
|
# YschOpenCall。svc-hall 未晋升 PlatformService 枚举,按名直调(完整 URL 拆 host+ctx,
|
||||||
|
# per-target base-path 与提供方 context-path 对齐 → 签名天然一致)。
|
||||||
|
# 容器直连形态(生产):http://svc-hall-service:10007/svc-hall(网关无 /oapi 路由)。
|
||||||
|
services:
|
||||||
|
svc-hall: http://localhost:8086
|
||||||
|
# 办事大厅(svc-hall)对接(V2.0 Part C.3):门户 service/guide 数据源。
|
||||||
|
# 双向信任:CMS 出站签名用 yh.app-auth 身份(appKey=100005,办事大厅侧须在 UserBase
|
||||||
|
# 注册表内可用);入站收 svc-hall 站点目录调用(appKey=100018,见上方 trusted-apps)。
|
||||||
|
# 地址不再单独配置:见上方 yh.open.services.svc-hall(私有 legacy 回落键已随 D1 目录键归一同步改名)。
|
||||||
|
# env-config 管理端运行时配置(免登录 GET /api/env-config?target=admin,数据源见
|
||||||
|
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||||
|
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_ADMIN
|
||||||
|
# 或同名键 yh.env-config.source.admin 注入(projman 配置管理维护),未配置时端点返回 {},
|
||||||
|
# 前端走同域推导兜底(serviceHost/baseHost 正确,ssoHost 为空登录跳转不可用)。
|
||||||
|
# 如需本地联调可打开:
|
||||||
|
# env-config:
|
||||||
|
# source:
|
||||||
|
# admin: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"/yschcms","isMock":false}'
|
||||||
|
|
||||||
# ===== CMS 专属配置 =====
|
# ===== CMS 专属配置 =====
|
||||||
cms:
|
cms:
|
||||||
@@ -78,6 +110,13 @@ cms:
|
|||||||
# 含 context-path,如 https://base.yunhaixingtong.com/cms;生产由 projman 注入。
|
# 含 context-path,如 https://base.yunhaixingtong.com/cms;生产由 projman 注入。
|
||||||
# 为空(本地开发默认)时按当前请求派生(反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
|
# 为空(本地开发默认)时按当前请求派生(反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
|
||||||
base-origin: ""
|
base-origin: ""
|
||||||
|
svc:
|
||||||
|
item:
|
||||||
|
# 【V2.0 拆分遗留说明】事项库已迁独立"办事大厅"应用(svc-hall),CMS 侧 SvcItemQuery
|
||||||
|
# 由 SvcItemRemoteQuery 经 oapi 调办事大厅;本配置已废弃(部门口径归办事大厅
|
||||||
|
# svc-hall.item.dept-group-type)。保留注释防误解。
|
||||||
|
dept-group-type: 7
|
||||||
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
@@ -91,6 +130,11 @@ management:
|
|||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
show-details: never
|
show-details: never
|
||||||
|
# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health
|
||||||
|
# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用(Notice/flow-center/svc-hall 同口径)
|
||||||
|
health:
|
||||||
|
redis:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
# 文件上传(同 UserHome 双 key 约定,方案 §7.3)
|
# 文件上传(同 UserHome 双 key 约定,方案 §7.3)
|
||||||
# FILE_WEB_PATH 只接受相对路径(默认 /cms/upload/,含 context-path)或公网可达绝对地址;
|
# FILE_WEB_PATH 只接受相对路径(默认 /cms/upload/,含 context-path)或公网可达绝对地址;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
-- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》)
|
-- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》)
|
||||||
CREATE TABLE IF NOT EXISTS `cms_site` (
|
CREATE TABLE IF NOT EXISTS `cms_site` (
|
||||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`code` VARCHAR(64) NOT NULL COMMENT '站点编码,URL 路径用(/portal/{code})',
|
`code` VARCHAR(64) NOT NULL COMMENT '站点编码(URL 末段;W4 起不再全局唯一)',
|
||||||
|
`path` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识',
|
||||||
`parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层)',
|
`parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层)',
|
||||||
`sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)',
|
`sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)',
|
||||||
`sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)',
|
`sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)',
|
||||||
@@ -21,7 +22,8 @@ CREATE TABLE IF NOT EXISTS `cms_site` (
|
|||||||
`update_by` VARCHAR(64) NULL,
|
`update_by` VARCHAR(64) NULL,
|
||||||
`update_time` DATETIME NULL,
|
`update_time` DATETIME NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `uk_site_code` (`code`),
|
UNIQUE KEY `uk_site_path` (`path`),
|
||||||
|
UNIQUE KEY `uk_site_parent_code` (`parent_site_id`, `code`),
|
||||||
KEY `idx_site_sch` (`sch_id`),
|
KEY `idx_site_sch` (`sch_id`),
|
||||||
KEY `idx_site_parent` (`parent_site_id`)
|
KEY `idx_site_parent` (`parent_site_id`)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)';
|
||||||
@@ -136,6 +138,69 @@ CREATE TABLE IF NOT EXISTS `cms_link` (
|
|||||||
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
KEY `idx_link` (`site_id`, `group_name`, `sort_no`)
|
||||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 友情链接';
|
||||||
|
|
||||||
|
-- 7. 事项表(L1 事项库主表,事项库与办事大厅详细设计 §2.1;跨服务键 = siteCode + item_code)
|
||||||
|
CREATE TABLE IF NOT EXISTS `cms_service_item` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
|
`item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)',
|
||||||
|
`title` VARCHAR(200) NOT NULL COMMENT '事项名称',
|
||||||
|
`summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)',
|
||||||
|
`object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)',
|
||||||
|
`category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)',
|
||||||
|
`dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)',
|
||||||
|
`dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)',
|
||||||
|
`handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下',
|
||||||
|
`online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)',
|
||||||
|
`handle_place` VARCHAR(200) NULL COMMENT '办理地点',
|
||||||
|
`handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")',
|
||||||
|
`promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)',
|
||||||
|
`contact_phone` VARCHAR(64) NULL COMMENT '咨询电话',
|
||||||
|
`supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话',
|
||||||
|
`process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)',
|
||||||
|
`material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)',
|
||||||
|
`notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)',
|
||||||
|
`faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)',
|
||||||
|
`notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)',
|
||||||
|
`rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)',
|
||||||
|
`cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)',
|
||||||
|
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架',
|
||||||
|
`publish_time` DATETIME NULL COMMENT '发布时间',
|
||||||
|
`offline_time` DATETIME NULL COMMENT '下架时间',
|
||||||
|
`view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)',
|
||||||
|
`ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)',
|
||||||
|
`create_by` VARCHAR(64) NULL,
|
||||||
|
`create_time` DATETIME NULL,
|
||||||
|
`update_by` VARCHAR(64) NULL,
|
||||||
|
`update_time` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`),
|
||||||
|
KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`),
|
||||||
|
KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`),
|
||||||
|
KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`),
|
||||||
|
KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`),
|
||||||
|
KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)';
|
||||||
|
|
||||||
|
-- 8. 事项字典表(服务对象/事项类别;不复用 cms_category——栏目树是内容目录,语义不同)
|
||||||
|
CREATE TABLE IF NOT EXISTS `cms_service_dict` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
|
`dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别',
|
||||||
|
`dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)',
|
||||||
|
`dict_name` VARCHAR(128) NOT NULL COMMENT '展示名',
|
||||||
|
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)',
|
||||||
|
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)',
|
||||||
|
`ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)',
|
||||||
|
`create_by` VARCHAR(64) NULL,
|
||||||
|
`create_time` DATETIME NULL,
|
||||||
|
`update_by` VARCHAR(64) NULL,
|
||||||
|
`update_time` DATETIME NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`),
|
||||||
|
KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 示例数据(联调首屏用,按需执行)
|
-- 示例数据(联调首屏用,按需执行)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="code">400</div>
|
<div class="code">400</div>
|
||||||
<h1>请求参数有误</h1>
|
<h1>请求参数有误</h1>
|
||||||
<p>页面地址中的参数不正确(如页码格式错误)。<br>请修正后重试,或返回门户首页继续浏览。</p>
|
<p>页面地址中的参数不正确(如页码格式错误)。<br>请修正后重试,或返回门户首页继续浏览。</p>
|
||||||
<a href="/cms/portal/">返回门户首页</a>
|
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="code">404</div>
|
<div class="code">404</div>
|
||||||
<h1>页面不存在或已下线</h1>
|
<h1>页面不存在或已下线</h1>
|
||||||
<p>您访问的页面可能已被删除、更改地址或暂时不可用。<br>请检查网址是否正确,或返回门户首页继续浏览。</p>
|
<p>您访问的页面可能已被删除、更改地址或暂时不可用。<br>请检查网址是否正确,或返回门户首页继续浏览。</p>
|
||||||
<a href="/cms/portal/">返回门户首页</a>
|
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="code">500</div>
|
<div class="code">500</div>
|
||||||
<h1>服务暂时不可用</h1>
|
<h1>服务暂时不可用</h1>
|
||||||
<p>系统开小差了,工程师正在抢修。<br>请稍后刷新重试,或返回门户首页继续浏览。</p>
|
<p>系统开小差了,工程师正在抢修。<br>请稍后刷新重试,或返回门户首页继续浏览。</p>
|
||||||
<a href="/cms/portal/">返回门户首页</a>
|
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="code">:(</div>
|
<div class="code">:(</div>
|
||||||
<h1>出错了</h1>
|
<h1>出错了</h1>
|
||||||
<p>请求处理出现异常,请稍后重试。<br>如持续出现,请联系管理员。</p>
|
<p>请求处理出现异常,请稍后重试。<br>如持续出现,请联系管理员。</p>
|
||||||
<a href="/cms/portal/">返回门户首页</a>
|
<a th:href="@{/portal/}" href="/portal/">返回门户首页</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# blue-white 皮肤包
|
|
||||||
|
|
||||||
由 default 派生的演示皮肤(方案 §7)。**升级随版本发布,不在站点间复制目录**。
|
|
||||||
|
|
||||||
## 数据契约(模板可取用的 model 属性全集,§4.3)
|
|
||||||
|
|
||||||
| 页面 | 属性 |
|
|
||||||
| --- | --- |
|
|
||||||
| 全部 | `site`(快照:config/分类树/轮播/友链组/院系子站)、`url`(PortalUrlHelper:home/category/article/asset/search/siteHome)、`themeBase`(=`themes/blue-white`)、`navCategories`、`pageTitle`、`seoDescription`、`canonical`、`themeParams`(微调参数,仅 theme.json 声明过的 key)、`themeVarsCss`(服务端预构建 `:root{}` 覆盖串,无参数为空) |
|
|
||||||
| home | `banners`(时间窗内 home 位)、`topArticles`(置顶+最新 6)、`categoryBlocks`(LIST 型根栏目块,各 top 8) |
|
|
||||||
| list / search | `listData`(分页)、`category`(list)、`q` + `searchMode`(search) |
|
|
||||||
| detail | `article`、`prevArticle`、`nextArticle`、`currentCategoryId` |
|
|
||||||
| single | `category`(含 `content`)、`currentCategoryId` |
|
|
||||||
|
|
||||||
原则:皮肤不得假设"首页一定有轮播",对空集合优雅降级。
|
|
||||||
|
|
||||||
## 轮播图尺寸约定(bannerSize)
|
|
||||||
|
|
||||||
1920x560(管理端上传端按此引导裁剪,D2 决策点)。
|
|
||||||
|
|
||||||
## 可调参数
|
|
||||||
|
|
||||||
见 `theme.json` 的 `params`(color 进 CSS 变量;select 供模板 `th:if` 分支布局)。
|
|
||||||
@@ -1,344 +0,0 @@
|
|||||||
/* ============================================================
|
|
||||||
* blue-white 皮肤包样式(default 派生)(方案 §8.2)
|
|
||||||
* 换肤 = 拷贝 default 目录改 CSS 变量与布局,零后端代码。
|
|
||||||
* 轮播图设计尺寸约定:1920×560(管理端上传端按此引导裁剪,D2 决策点确认)。
|
|
||||||
* ============================================================ */
|
|
||||||
:root {
|
|
||||||
--primary: #2f6bd8;
|
|
||||||
--primary-dark: #1d4fae;
|
|
||||||
--text: #333;
|
|
||||||
--text-light: #777;
|
|
||||||
--bg: #f7f9fc;
|
|
||||||
--card-bg: #fff;
|
|
||||||
--border: #e3e9f2;
|
|
||||||
--max-width: 1200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
a { color: inherit; text-decoration: none; }
|
|
||||||
a:hover { color: var(--primary); }
|
|
||||||
img { max-width: 100%; }
|
|
||||||
time, .item-meta { color: var(--text-light); font-size: 13px; }
|
|
||||||
.empty { color: var(--text-light); text-align: center; padding: 32px 0; }
|
|
||||||
|
|
||||||
/* ===== 头部 ===== */
|
|
||||||
.site-header { background: var(--card-bg); border-bottom: 1px solid var(--border); }
|
|
||||||
.header-inner {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 18px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.site-brand { display: flex; align-items: center; gap: 12px; }
|
|
||||||
.site-logo { height: 44px; }
|
|
||||||
.site-name { font-size: 26px; font-weight: 700; color: var(--primary); }
|
|
||||||
.site-search { display: flex; }
|
|
||||||
.site-search input {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-right: none;
|
|
||||||
padding: 8px 12px;
|
|
||||||
width: 220px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.site-search button {
|
|
||||||
border: none;
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
|
||||||
padding: 8px 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 专业院系下拉(院系子站切换,《院系子站方案》) ===== */
|
|
||||||
/* 悬停展开白底纵向列表,右对齐 + 超高滚动(院系数十个子站场景) */
|
|
||||||
.site-colleges { position: relative; }
|
|
||||||
.site-colleges .colleges-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 8px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--primary);
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.site-colleges .colleges-arrow { font-style: normal; font-size: 12px; color: var(--text-light); }
|
|
||||||
.site-colleges .colleges-menu {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
min-width: 200px;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 0;
|
|
||||||
list-style: none;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-top: 2px solid var(--primary);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, .12);
|
|
||||||
z-index: 110;
|
|
||||||
}
|
|
||||||
.site-colleges:hover .colleges-menu { display: block; }
|
|
||||||
.site-colleges .colleges-menu li a {
|
|
||||||
display: block;
|
|
||||||
padding: 9px 16px;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.site-colleges .colleges-menu li a:hover { background: #f2f6fc; color: var(--primary); }
|
|
||||||
.site-colleges .colleges-menu li.active a { color: var(--primary); font-weight: 600; }
|
|
||||||
.site-nav { background: var(--primary); }
|
|
||||||
.site-nav ul {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 16px;
|
|
||||||
list-style: none;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.site-nav li a {
|
|
||||||
display: block;
|
|
||||||
padding: 12px 22px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.site-nav li:hover a, .site-nav li.active a { background: var(--primary-dark); }
|
|
||||||
|
|
||||||
/* ===== 二级下拉导航 ===== */
|
|
||||||
/* 面板为白底纵向列表:需整体覆盖 .site-nav ul 的横向 flex 与内边距 */
|
|
||||||
.site-nav > ul > li { position: relative; }
|
|
||||||
.site-nav li.has-sub > a::after { content: " ▾"; font-size: 12px; opacity: .85; }
|
|
||||||
.site-nav ul.sub-nav {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
min-width: 168px;
|
|
||||||
max-width: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 0;
|
|
||||||
list-style: none;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-top: 2px solid var(--primary);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, .12);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
.site-nav li:hover > .sub-nav { display: block; }
|
|
||||||
.site-nav .sub-nav li a {
|
|
||||||
padding: 9px 16px;
|
|
||||||
color: var(--text);
|
|
||||||
white-space: nowrap;
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
.site-nav .sub-nav li a:hover { background: #f2f6fc; color: var(--primary); }
|
|
||||||
.site-nav .sub-nav li.active a { color: var(--primary); font-weight: 600; }
|
|
||||||
|
|
||||||
/* ===== 首页轮播 ===== */
|
|
||||||
.banner { position: relative; max-width: var(--max-width); margin: 16px auto; overflow: hidden; }
|
|
||||||
.banner-slides { position: relative; height: 420px; }
|
|
||||||
.banner-slide { display: none; position: absolute; inset: 0; }
|
|
||||||
.banner-slide.active { display: block; }
|
|
||||||
.banner-slide img { width: 100%; height: 100%; object-fit: cover; }
|
|
||||||
.banner-title {
|
|
||||||
position: absolute;
|
|
||||||
left: 0; right: 0; bottom: 0;
|
|
||||||
padding: 12px 20px;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(transparent, rgba(0, 0, 0, .6));
|
|
||||||
}
|
|
||||||
.banner-dots {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px; right: 20px;
|
|
||||||
display: flex; gap: 8px;
|
|
||||||
}
|
|
||||||
.banner-dots button {
|
|
||||||
width: 10px; height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: rgba(255, 255, 255, .6);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.banner-dots button.active { background: #fff; }
|
|
||||||
|
|
||||||
/* ===== 通用区块 ===== */
|
|
||||||
.top-strip, .category-block {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 16px auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.section-title, .block-head h2 {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 20px;
|
|
||||||
border-left: 4px solid var(--primary);
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
.block-head { display: flex; align-items: baseline; justify-content: space-between; }
|
|
||||||
.more { font-size: 13px; color: var(--text-light); }
|
|
||||||
.top-strip ul, .block-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.top-strip li, .block-list li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 9px 0;
|
|
||||||
border-bottom: 1px dashed var(--border);
|
|
||||||
}
|
|
||||||
.top-strip li:last-child, .block-list li:last-child { border-bottom: none; }
|
|
||||||
.top-strip li a, .block-list li a {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.top-badge {
|
|
||||||
flex: none;
|
|
||||||
color: #fff;
|
|
||||||
background: var(--primary);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 列表页 ===== */
|
|
||||||
.list-main {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 16px auto;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.breadcrumb { color: var(--text-light); font-size: 13px; margin-bottom: 16px; }
|
|
||||||
.breadcrumb a { margin-right: 8px; }
|
|
||||||
.breadcrumb span { margin-left: 8px; }
|
|
||||||
.page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
border-left: 4px solid var(--primary);
|
|
||||||
padding-left: 10px;
|
|
||||||
margin: 0 0 18px;
|
|
||||||
}
|
|
||||||
.article-list { list-style: none; margin: 0; padding: 0; }
|
|
||||||
.article-list li { padding: 14px 0; border-bottom: 1px dashed var(--border); }
|
|
||||||
.item-title { font-size: 17px; }
|
|
||||||
.item-summary {
|
|
||||||
color: var(--text-light);
|
|
||||||
font-size: 14px;
|
|
||||||
margin: 6px 0;
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
.item-meta { display: flex; gap: 10px; align-items: center; }
|
|
||||||
|
|
||||||
/* ===== 分页 ===== */
|
|
||||||
.pagination { display: flex; gap: 6px; justify-content: center; padding: 24px 0 4px; }
|
|
||||||
.pagination a {
|
|
||||||
min-width: 34px;
|
|
||||||
text-align: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--card-bg);
|
|
||||||
}
|
|
||||||
.pagination a.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
||||||
|
|
||||||
/* ===== 详情/单页 ===== */
|
|
||||||
.detail-main {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 16px auto;
|
|
||||||
padding: 32px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.article-title { margin: 8px 0 12px; font-size: 26px; text-align: center; }
|
|
||||||
.article-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 18px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
color: var(--text-light);
|
|
||||||
font-size: 13px;
|
|
||||||
padding-bottom: 18px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.article-content { line-height: 1.9; font-size: 16px; padding-top: 20px; word-break: break-word; }
|
|
||||||
.article-content img { height: auto; }
|
|
||||||
.article-content table { border-collapse: collapse; width: 100%; }
|
|
||||||
.article-content table td, .article-content table th { border: 1px solid var(--border); padding: 6px 10px; }
|
|
||||||
.article-sibling { display: flex; flex-direction: column; gap: 8px; margin-top: 28px; font-size: 14px; }
|
|
||||||
|
|
||||||
/* ===== 页脚 ===== */
|
|
||||||
.site-footer { margin-top: 32px; background: var(--primary-dark); color: rgba(255, 255, 255, .85); }
|
|
||||||
.footer-links {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 18px 16px 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.links-group { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; font-size: 13px; }
|
|
||||||
.group-name { font-weight: 600; margin-right: 4px; }
|
|
||||||
.footer-info {
|
|
||||||
max-width: var(--max-width);
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 12px 16px 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.footer-info p { margin: 4px 0; }
|
|
||||||
|
|
||||||
/* ===== 移动端适配 ===== */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-inner { flex-direction: column; align-items: stretch; }
|
|
||||||
.site-search input { flex: 1; width: auto; }
|
|
||||||
.site-colleges { align-self: flex-start; }
|
|
||||||
.site-colleges .colleges-menu { left: 0; right: auto; min-width: 100%; }
|
|
||||||
.banner-slides { height: 200px; }
|
|
||||||
.detail-main, .list-main { padding: 18px 14px; }
|
|
||||||
.article-title { font-size: 22px; }
|
|
||||||
/* 触屏无 hover:子菜单静态平铺(缩进列表),不做浮层 */
|
|
||||||
.site-nav ul.sub-nav {
|
|
||||||
display: block;
|
|
||||||
position: static;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
background: transparent;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0 0 6px;
|
|
||||||
}
|
|
||||||
.site-nav .sub-nav li a { color: rgba(255, 255, 255, .9); padding: 8px 16px 8px 34px; }
|
|
||||||
.site-nav .sub-nav li a:hover { background: var(--primary-dark); color: #fff; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ===== blue-white 主题差异层(方案 §7:差异仅限 CSS + fragments)===== */
|
|
||||||
/* 细线顶栏 + 大留白 + 卡片化列表 */
|
|
||||||
.site-header { border-bottom: 1px solid var(--border); box-shadow: none; }
|
|
||||||
.home-main, .list-main, .detail-main { padding-top: 36px; padding-bottom: 56px; }
|
|
||||||
.article-list .article-title, .block-head, .page-title { letter-spacing: .02em; }
|
|
||||||
.article-list li, .banner, .banner-title { border-radius: 4px; }
|
|
||||||
|
|
||||||
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
|
||||||
.links-group a .link-logo,
|
|
||||||
.sp-links-row a .link-logo {
|
|
||||||
height: 20px;
|
|
||||||
width: auto;
|
|
||||||
max-width: 64px;
|
|
||||||
object-fit: contain;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,8 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
|
||||||
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stop-color="#5486df"/><stop offset="1" stop-color="#214b97"/>
|
|
||||||
</linearGradient></defs>
|
|
||||||
<rect width="64" height="64" fill="url(#bg)"/>
|
|
||||||
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
|
|
||||||
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 434 B |
@@ -1,40 +0,0 @@
|
|||||||
/* default 皮肤包脚本:仅首页轮播自动播放与圆点切换,无任何外部依赖 */
|
|
||||||
(function () {
|
|
||||||
var slides = document.querySelectorAll('.banner-slide');
|
|
||||||
var dots = document.querySelectorAll('.banner-dots button');
|
|
||||||
if (!slides.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var current = 0;
|
|
||||||
var timer = null;
|
|
||||||
|
|
||||||
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() {
|
|
||||||
if (slides.length > 1) {
|
|
||||||
timer = window.setInterval(function () {
|
|
||||||
show(current + 1);
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dots.forEach(function (dot, index) {
|
|
||||||
dot.addEventListener('click', function () {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
show(index);
|
|
||||||
start();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
start();
|
|
||||||
})();
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB |
@@ -1,42 +0,0 @@
|
|||||||
<!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="detail-main">
|
|
||||||
<nav class="breadcrumb">
|
|
||||||
<a th:href="${url.home()}">首页</a>
|
|
||||||
<a th:if="${currentCategoryId != null}" th:href="${url.category(currentCategoryId)}">栏目</a>
|
|
||||||
<span>正文</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="article">
|
|
||||||
<h1 class="article-title" th:text="${article.title}">文章标题</h1>
|
|
||||||
<div class="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="article-content" th:utext="${article.content}">正文</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<nav class="article-sibling">
|
|
||||||
<a th:if="${prevArticle != null}" class="prev"
|
|
||||||
th:href="${url.article(prevArticle.id)}"
|
|
||||||
th:text="'上一篇:' + ${prevArticle.title}">上一篇</a>
|
|
||||||
<a th:if="${nextArticle != null}" class="next"
|
|
||||||
th:href="${url.article(nextArticle.id)}"
|
|
||||||
th:text="'下一篇:' + ${nextArticle.title}">下一篇</a>
|
|
||||||
</nav>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<!-- 公共页脚:联系信息 + 友情链接(分组渲染,方案 §8.1:友链不设独立路由) -->
|
|
||||||
<footer class="site-footer" th:fragment="footer">
|
|
||||||
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
|
||||||
<div class="links-group" th:each="group : ${site.linkGroups}">
|
|
||||||
<span class="group-name" th:text="${group.groupName}">分组</span>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="footer-info">
|
|
||||||
<p th:if="${site.config['contact'] != null}" th:text="${site.config['contact']}">联系方式</p>
|
|
||||||
<p th:if="${site.config['copyright'] != null}" th:text="${site.config['copyright']}">版权信息</p>
|
|
||||||
<p th:if="${site.config['beian'] != null}" th:text="${site.config['beian']}">备案号</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</html>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
|
||||||
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
|
|
||||||
<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>
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html xmlns:th="http://www.thymeleaf.org">
|
|
||||||
<!-- 公共头部:站点名 + 栏目导航 + 站内搜索 + 专业院系下拉(院系子站切换,《院系子站方案》) -->
|
|
||||||
<header class="site-header" th:fragment="header">
|
|
||||||
<div class="header-inner">
|
|
||||||
<div class="site-brand">
|
|
||||||
<img th:if="${site.config['logo'] != null}" class="site-logo"
|
|
||||||
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">
|
|
||||||
<input type="text" name="q" placeholder="站内搜索" aria-label="站内搜索">
|
|
||||||
<button type="submit">搜索</button>
|
|
||||||
</form>
|
|
||||||
<!-- 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身);无子站不渲染;文案经 config_json.colleges_label 覆盖 -->
|
|
||||||
<div class="site-colleges" th:if="${!site.colleges.isEmpty()}"
|
|
||||||
th:with="collegesLabel=${site.config['colleges_label'] ?: '专业院系'}">
|
|
||||||
<span class="colleges-toggle">
|
|
||||||
<a href="javascript:void(0)" th:text="${collegesLabel}">专业院系</a>
|
|
||||||
<i class="colleges-arrow">▾</i>
|
|
||||||
</span>
|
|
||||||
<ul class="colleges-menu">
|
|
||||||
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'">
|
|
||||||
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}"
|
|
||||||
th:title="${col.name}" th:text="${col.name}">学院名称</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav class="site-nav">
|
|
||||||
<ul>
|
|
||||||
<li th:classappend="${currentCategoryId == null} ? 'active'">
|
|
||||||
<a th:href="${url.home()}">首页</a>
|
|
||||||
</li>
|
|
||||||
<li th:each="cat : ${navCategories}"
|
|
||||||
th:classappend="${(currentCategoryId == cat.id ? 'active' : '') + (!cat.children.isEmpty() ? ' has-sub' : '')}">
|
|
||||||
<!-- 外链栏目直接外跳;其余进栏目页(方案 §8.1) -->
|
|
||||||
<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)}" th:text="${cat.name}">栏目</a>
|
|
||||||
<!-- 二级下拉:悬停展开(样式见 assets/css/main.css .sub-nav) -->
|
|
||||||
<ul class="sub-nav" th:if="${!cat.children.isEmpty()}">
|
|
||||||
<li th:each="sub : ${cat.children}">
|
|
||||||
<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>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<!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="home-main">
|
|
||||||
<!-- 首页轮播位(cms_banner position=home;设计尺寸约定 1920×560,见 assets/css 注释) -->
|
|
||||||
<section class="banner" th:if="${!#lists.isEmpty(banners)}">
|
|
||||||
<div class="banner-slides">
|
|
||||||
<a class="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="banner-title" th:text="${b.title}">标题</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="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>
|
|
||||||
|
|
||||||
<!-- 置顶/头条区(cms_article topmost 优先) -->
|
|
||||||
<section class="top-strip" th:if="${!#lists.isEmpty(topArticles)}">
|
|
||||||
<h2 class="section-title">头条推荐</h2>
|
|
||||||
<ul>
|
|
||||||
<li th:each="item : ${topArticles}">
|
|
||||||
<span class="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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 一级栏目区块(每栏目 top 8) -->
|
|
||||||
<section class="category-block" th:each="block : ${categoryBlocks}">
|
|
||||||
<div class="block-head">
|
|
||||||
<h2 th:text="${block.category.name}">栏目名</h2>
|
|
||||||
<a class="more" th:href="${block.moreUrl}">更多 ></a>
|
|
||||||
</div>
|
|
||||||
<ul class="block-list" th:if="${!#lists.isEmpty(block.items)}">
|
|
||||||
<li th:each="item : ${block.items}">
|
|
||||||
<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>
|
|
||||||
<p class="empty" th:if="${#lists.isEmpty(block.items)}">暂无内容</p>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
|
||||||
<script th:src="${url.asset('js/main.js')}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<!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="list-main">
|
|
||||||
<nav class="breadcrumb" th:if="${searchMode != true}">
|
|
||||||
<a th:href="${url.home()}">首页</a>
|
|
||||||
<span th:text="${category.name}">栏目</span>
|
|
||||||
</nav>
|
|
||||||
<nav class="breadcrumb" th:if="${searchMode == true}">
|
|
||||||
<a th:href="${url.home()}">首页</a>
|
|
||||||
<span>搜索结果</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<h1 class="page-title" th:if="${searchMode != true}" th:text="${category.name}">栏目名</h1>
|
|
||||||
<h1 class="page-title" th:if="${searchMode == true}" th:text="'搜索:' + ${q}">搜索词</h1>
|
|
||||||
|
|
||||||
<ul class="article-list">
|
|
||||||
<li th:each="item : ${listData.items}">
|
|
||||||
<a class="item-title" th:href="${url.article(item.id)}" th:text="${item.title}">文章标题</a>
|
|
||||||
<p class="item-summary" th:if="${!#strings.isEmpty(item.summary)}" th:text="${item.summary}">摘要</p>
|
|
||||||
<div class="item-meta">
|
|
||||||
<time th:if="${item.publishTime != null}"
|
|
||||||
th:text="${#temporals.format(item.publishTime, 'yyyy-MM-dd')}">2026-01-01</time>
|
|
||||||
<span class="top-badge" th:if="${item.topmost == 1}">置顶</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p class="empty" th:if="${#lists.isEmpty(listData.items)}">暂无内容</p>
|
|
||||||
|
|
||||||
<!-- 分页(列表页/搜索页共用) -->
|
|
||||||
<nav class="pagination" th:if="${listData.totalPages > 1}"
|
|
||||||
th:with="cur=${listData.page}, total=${listData.totalPages}">
|
|
||||||
<a th:if="${cur > 1}"
|
|
||||||
th:href="${searchMode == true} ? ${url.search(q, cur - 1)} : ${url.category(listData.categoryId) + '?page=' + (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:text="${p}">1</a>
|
|
||||||
<a th:if="${cur < total}"
|
|
||||||
th:href="${searchMode == true} ? ${url.search(q, cur + 1)} : ${url.category(listData.categoryId) + '?page=' + (cur + 1)}">下一页</a>
|
|
||||||
</nav>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
|
||||||
<script th:src="${url.asset('js/main.js')}"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!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="detail-main">
|
|
||||||
<nav class="breadcrumb">
|
|
||||||
<a th:href="${url.home()}">首页</a>
|
|
||||||
<span th:text="${category.name}">栏目</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<article class="article">
|
|
||||||
<h1 class="article-title" th:text="${category.name}">栏目名</h1>
|
|
||||||
<!-- 单页栏目内容(type=2,入库前已 jsoup 清洗) -->
|
|
||||||
<div class="article-content" th:utext="${category.content}">单页内容</div>
|
|
||||||
<p class="empty" th:if="${#strings.isEmpty(category.content)}">暂无内容</p>
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer th:replace="~{${themeBase} + '/fragments/footer' :: footer}"></footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"specVersion": 1,
|
|
||||||
"name": "blue-white",
|
|
||||||
"title": "蓝白简约",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "yunhai",
|
|
||||||
"description": "细线顶栏 + 卡片化列表 + 大留白,适合高校与职业院校(方案 §7)。",
|
|
||||||
"screenshot": "assets/screenshot.png",
|
|
||||||
"bannerSize": "1920x560",
|
|
||||||
"params": [
|
|
||||||
{ "key": "primary", "cssVar": "--primary", "label": "主色", "type": "color", "default": "#2f6bd8" },
|
|
||||||
{ "key": "primaryDark", "cssVar": "--primary-dark", "label": "主色加深", "type": "color", "default": "#1d4fae" },
|
|
||||||
{ "key": "bg", "cssVar": "--bg", "label": "页面底色", "type": "color", "default": "#f7f9fc" },
|
|
||||||
{ "key": "border", "cssVar": "--border", "label": "边框色", "type": "color", "default": "#e3e9f2" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# campus-glow(炫彩校园)
|
||||||
|
|
||||||
|
展示型皮肤包(docs/12 方案的首个落地消费方):在共享数据契约之上,为首页提供四类「炫」呈现,并大幅增强页脚个性化能力。
|
||||||
|
|
||||||
|
## 风格定位
|
||||||
|
|
||||||
|
- 主色翠绿 `#0e8a5f` + 强调橙 `#ff7a1a`(gradual 渐变与毛玻璃质感),适配综合性大学/职业院校门户的「鲜活」诉求。
|
||||||
|
- 全部颜色可在站点 theme_params 中微调;布局类参数仅 `galleryCols`(线上展厅每行列数 3/4/5)。
|
||||||
|
|
||||||
|
## 首页板块形态(栏目顺序即板块顺序)
|
||||||
|
|
||||||
|
| 栏目 homeStyle | 呈现 | 数据 |
|
||||||
|
| -- | -- | -- |
|
||||||
|
| (空) | 默认卡:featured 首条 + 行式列表 | categoryBlocks |
|
||||||
|
| `news_grid` | 双栏新闻卡:全幅背景(站点 config `news_bg_image`,未配走主题渐变)+ 半透明毛玻璃卡 + MM-DD 日期前缀双栏列表(16 条) | categoryBlocks |
|
||||||
|
| `media_focus` | 媒体聚焦:来源徽标(文章 `source` 字段,空则只显示标题)+ 标题双栏列表 | categoryBlocks |
|
||||||
|
| 独立板块 | 线上展厅画廊:双语文区头(config `gallery_title`/`gallery_subtitle`)+ 横向翻页轮播 | 轮播 position=`gallery`(设计尺寸 800×520) |
|
||||||
|
|
||||||
|
另有「要闻速递」横条(置顶文章无缝滚动,hover 暂停)与通栏轮播(1920×560)。
|
||||||
|
|
||||||
|
## 页脚能力(站点 config 驱动,全部有值才渲染)
|
||||||
|
|
||||||
|
`footer_addresses`(多校区地址,换行分行)· `qr_wechat`/`qr_weibo`(二维码,hover 放大)·
|
||||||
|
`social_wechat`/`social_weibo`/`social_douyin`/`social_bilibili`(社媒六边形图标阵列)·
|
||||||
|
`contact`/`copyright`/`beian`/logo/友链分组/校名底条(既有契约)· 返回顶部悬浮按钮(主题内置)。
|
||||||
|
|
||||||
|
## 参数取值建议
|
||||||
|
|
||||||
|
- 换主色:`primary`/`primaryDark`/`accent` 三者成对调整;浅色校徽建议保留页脚品牌底衬。
|
||||||
|
- 展厅图片比例接近 800×520 时观感最佳;不足一页时画廊自动隐藏翻页按钮且不轮播。
|
||||||
@@ -0,0 +1,656 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
campus-glow 炫彩校园(docs/12 展示型皮肤)
|
||||||
|
设计令牌:theme.json color 参数经服务端聚合 :root 覆盖(优先级 custom_css > themeVarsCss > 本文件)
|
||||||
|
===================================================================== */
|
||||||
|
:root {
|
||||||
|
--cg-primary: #0e8a5f;
|
||||||
|
--cg-primary-dark: #0a6244;
|
||||||
|
--cg-accent: #ff7a1a;
|
||||||
|
--cg-text: #2b2f33;
|
||||||
|
--cg-text-light: #7a8288;
|
||||||
|
--cg-bg: #f4f7f5;
|
||||||
|
--cg-card-bg: #ffffff;
|
||||||
|
--cg-border: #e3e8e4;
|
||||||
|
--cg-shadow: 0 10px 30px rgba(10, 98, 68, 0.10);
|
||||||
|
--cg-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: var(--cg-text);
|
||||||
|
background: var(--cg-bg);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
img { max-width: 100%; border: 0; display: block; }
|
||||||
|
ul, ol, p, figure { margin: 0; padding: 0; }
|
||||||
|
li { list-style: none; }
|
||||||
|
a { color: inherit; text-decoration: none; transition: color .2s; }
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
|
||||||
|
.cg-container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
|
||||||
|
.cg-skip-link {
|
||||||
|
position: absolute; left: -999px; top: 0; z-index: 300;
|
||||||
|
background: var(--cg-primary); color: #fff; padding: 8px 14px; border-radius: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
.cg-skip-link:focus { left: 0; }
|
||||||
|
|
||||||
|
/* ===== 顶条 ===== */
|
||||||
|
.cg-topbar {
|
||||||
|
background: var(--cg-primary-dark);
|
||||||
|
color: rgba(255, 255, 255, .85);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cg-topbar-inner { display: flex; justify-content: space-between; align-items: center; min-height: 34px; }
|
||||||
|
.cg-colleges { position: relative; }
|
||||||
|
.cg-colleges-toggle { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; padding: 4px 0; }
|
||||||
|
.cg-colleges-arrow { font-size: 10px; transition: transform .2s; }
|
||||||
|
.cg-colleges-menu {
|
||||||
|
display: none; position: absolute; top: 100%; right: 0; z-index: 120;
|
||||||
|
min-width: 200px; max-height: 420px; overflow-y: auto;
|
||||||
|
background: #fff; color: var(--cg-text);
|
||||||
|
border: 1px solid var(--cg-border); border-top: 2px solid var(--cg-primary);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, .12); padding: 6px 0;
|
||||||
|
}
|
||||||
|
.cg-colleges:hover .cg-colleges-menu, .cg-colleges:focus-within .cg-colleges-menu { display: block; }
|
||||||
|
.cg-colleges:hover .cg-colleges-arrow { transform: rotate(180deg); }
|
||||||
|
.cg-colleges-menu li a { display: block; padding: 7px 16px; font-size: 13px; }
|
||||||
|
.cg-colleges-menu li a:hover { background: color-mix(in srgb, var(--cg-primary) 8%, #fff); color: var(--cg-primary); }
|
||||||
|
.cg-colleges-menu li.active a { color: var(--cg-primary); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== 品牌行 ===== */
|
||||||
|
.cg-brand-row { display: flex; justify-content: space-between; align-items: center; padding-top: 22px; padding-bottom: 22px; gap: 16px; flex-wrap: wrap; }
|
||||||
|
.cg-site-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||||
|
.cg-site-logo { max-height: 56px; max-width: 220px; width: auto; }
|
||||||
|
.cg-site-name { font-size: 26px; font-weight: 700; color: var(--cg-primary-dark); letter-spacing: 1px; }
|
||||||
|
.cg-site-search { display: flex; }
|
||||||
|
.cg-site-search input {
|
||||||
|
width: 240px; height: 38px; padding: 0 14px; font-size: 14px;
|
||||||
|
border: 2px solid var(--cg-primary); border-right: 0; border-radius: 19px 0 0 19px; outline: none;
|
||||||
|
background: #fff; color: var(--cg-text);
|
||||||
|
}
|
||||||
|
.cg-site-search button {
|
||||||
|
height: 38px; padding: 0 20px; border: 0; border-radius: 0 19px 19px 0;
|
||||||
|
background: var(--cg-primary); color: #fff; font-size: 14px;
|
||||||
|
}
|
||||||
|
.cg-site-search button:hover { background: var(--cg-primary-dark); }
|
||||||
|
|
||||||
|
/* ===== 栏目导航 ===== */
|
||||||
|
.cg-nav { background: linear-gradient(90deg, var(--cg-primary-dark), var(--cg-primary)); box-shadow: var(--cg-shadow); position: sticky; top: 0; z-index: 100; }
|
||||||
|
.cg-nav > ul { display: flex; flex-wrap: wrap; }
|
||||||
|
.cg-nav > ul > li { position: relative; }
|
||||||
|
.cg-nav > ul > li > a {
|
||||||
|
display: flex; align-items: center; gap: 3px; color: #fff; font-size: 16px;
|
||||||
|
padding: 13px 22px; position: relative;
|
||||||
|
}
|
||||||
|
.cg-nav > ul > li > a:hover, .cg-nav > ul > li.active > a { background: rgba(255, 255, 255, .14); }
|
||||||
|
.cg-nav > ul > li.active > a::after {
|
||||||
|
content: ""; position: absolute; left: 22px; right: 22px; bottom: 6px; height: 3px;
|
||||||
|
border-radius: 2px; background: var(--cg-accent);
|
||||||
|
}
|
||||||
|
.cg-caret { font-size: 10px; opacity: .8; }
|
||||||
|
.cg-sub-nav {
|
||||||
|
display: none; position: absolute; top: 100%; left: 0; z-index: 110; min-width: 176px;
|
||||||
|
background: #fff; border: 1px solid var(--cg-border); border-top: 2px solid var(--cg-accent);
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, .12); padding: 6px 0;
|
||||||
|
}
|
||||||
|
.cg-nav li:hover .cg-sub-nav, .cg-nav li:focus-within .cg-sub-nav { display: block; }
|
||||||
|
.cg-sub-nav a { display: block; padding: 8px 16px; font-size: 14px; color: var(--cg-text); }
|
||||||
|
.cg-sub-nav a:hover { background: color-mix(in srgb, var(--cg-primary) 8%, #fff); color: var(--cg-primary); }
|
||||||
|
|
||||||
|
/* ===== 轮播 ===== */
|
||||||
|
.cg-banner { position: relative; overflow: hidden; background: var(--cg-primary-dark); }
|
||||||
|
.cg-banner-slide { display: none; position: relative; }
|
||||||
|
.cg-banner-slide.active { display: block; }
|
||||||
|
.cg-banner-slide img { width: 100%; height: auto; aspect-ratio: 1920 / 560; object-fit: cover; }
|
||||||
|
.cg-banner-cap {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; padding: 42px 6% 20px;
|
||||||
|
color: #fff; font-size: 20px; font-weight: 600; text-shadow: 0 1px 4px rgba(0, 0, 0, .5);
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .45));
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.cg-banner-arrow, .cg-gallery-arrow {
|
||||||
|
position: absolute; top: 50%; transform: translateY(-50%); z-index: 5;
|
||||||
|
width: 42px; height: 42px; border: 0; border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, .30); color: #fff; font-size: 24px; line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background .2s;
|
||||||
|
}
|
||||||
|
.cg-banner-arrow:hover, .cg-gallery-arrow:hover { background: var(--cg-accent); }
|
||||||
|
.cg-banner-arrow.prev, .cg-gallery-arrow.prev { left: 18px; }
|
||||||
|
.cg-banner-arrow.next, .cg-gallery-arrow.next { right: 18px; }
|
||||||
|
.cg-banner-dots { position: absolute; left: 0; right: 0; bottom: 14px; display: flex; justify-content: center; gap: 9px; z-index: 5; }
|
||||||
|
.cg-banner-dots button {
|
||||||
|
width: 10px; height: 10px; padding: 0; border: 0; border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, .55);
|
||||||
|
}
|
||||||
|
.cg-banner-dots button.active { background: var(--cg-accent); width: 24px; border-radius: 5px; }
|
||||||
|
|
||||||
|
/* ===== 要闻速递横条 ===== */
|
||||||
|
.cg-ticker { background: var(--cg-card-bg); border-bottom: 1px solid var(--cg-border); }
|
||||||
|
.cg-ticker-inner { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.cg-ticker-label {
|
||||||
|
flex: none; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
color: #fff; font-size: 14px; font-weight: 600; padding: 9px 16px;
|
||||||
|
background: linear-gradient(135deg, var(--cg-accent), #ff9f4d); border-radius: 0 0 12px 12px;
|
||||||
|
}
|
||||||
|
.cg-ticker-viewport { overflow: hidden; flex: 1; min-width: 0; }
|
||||||
|
.cg-ticker-track { display: inline-flex; white-space: nowrap; animation: cg-ticker-move 36s linear infinite; }
|
||||||
|
.cg-ticker:hover .cg-ticker-track { animation-play-state: paused; }
|
||||||
|
.cg-ticker-item { display: inline-flex; align-items: center; gap: 8px; padding: 10px 26px; font-size: 14px; color: var(--cg-text); }
|
||||||
|
.cg-ticker-item:hover { color: var(--cg-primary); }
|
||||||
|
.cg-ticker-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cg-accent); flex: none; }
|
||||||
|
@keyframes cg-ticker-move { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
||||||
|
|
||||||
|
/* ===== 区块通用头 ===== */
|
||||||
|
.cg-sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||||
|
.cg-sec-head h2 {
|
||||||
|
position: relative; font-size: 26px; font-weight: 700; color: var(--cg-primary-dark);
|
||||||
|
padding-left: 14px; letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.cg-sec-head h2::before {
|
||||||
|
content: ""; position: absolute; left: 0; top: 7px; bottom: 7px; width: 5px;
|
||||||
|
border-radius: 3px; background: linear-gradient(180deg, var(--cg-accent), var(--cg-primary));
|
||||||
|
}
|
||||||
|
.cg-more-circle {
|
||||||
|
flex: none; width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border: 2px solid var(--cg-primary); color: var(--cg-primary); background: transparent;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.cg-more-circle:hover { background: var(--cg-primary); color: #fff; transform: translateX(3px); }
|
||||||
|
.cg-more-text { color: var(--cg-text-light); font-size: 14px; }
|
||||||
|
.cg-more-text:hover { color: var(--cg-primary); }
|
||||||
|
.cg-top-badge {
|
||||||
|
flex: none; font-size: 12px; color: #fff; background: var(--cg-accent);
|
||||||
|
border-radius: 4px; padding: 1px 7px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 形态一:双栏新闻卡(全幅背景 + 毛玻璃) ===== */
|
||||||
|
.cg-newshot { position: relative; padding: 46px 0 52px; overflow: hidden; background: linear-gradient(135deg, var(--cg-primary-dark), var(--cg-primary) 55%, #35b98a); }
|
||||||
|
.cg-newshot-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
|
||||||
|
.cg-newshot-bg::after { content: ""; position: absolute; inset: 0; background: linear-gradient(120deg, rgba(10, 98, 68, .55), rgba(10, 98, 68, .18)); }
|
||||||
|
.cg-newshot .cg-container { position: relative; z-index: 2; }
|
||||||
|
.cg-newshot-card {
|
||||||
|
background: rgba(255, 255, 255, .88);
|
||||||
|
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, .65); border-radius: var(--cg-radius);
|
||||||
|
box-shadow: var(--cg-shadow); padding: 30px 34px 26px;
|
||||||
|
}
|
||||||
|
.cg-newshot-card .cg-sec-head h2 { color: var(--cg-primary); }
|
||||||
|
.cg-news-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; row-gap: 2px; }
|
||||||
|
.cg-news-grid li { display: flex; align-items: center; gap: 12px; padding: 10px 2px; border-bottom: 1px dashed var(--cg-border); min-width: 0; }
|
||||||
|
.cg-news-grid li:nth-last-child(-n+2) { border-bottom: 0; }
|
||||||
|
.cg-news-date { flex: none; color: var(--cg-accent); font-size: 15px; font-weight: 600; letter-spacing: .5px; }
|
||||||
|
.cg-news-title { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cg-news-title:hover { color: var(--cg-primary); }
|
||||||
|
|
||||||
|
/* ===== 形态二:媒体聚焦(来源徽标 + 标题) ===== */
|
||||||
|
.cg-media { padding: 46px 0 8px; }
|
||||||
|
.cg-media-card {
|
||||||
|
background: var(--cg-card-bg); border: 1px solid var(--cg-border);
|
||||||
|
border-radius: var(--cg-radius); box-shadow: var(--cg-shadow); padding: 30px 34px 26px;
|
||||||
|
}
|
||||||
|
.cg-media-grid { display: grid; grid-template-columns: 1fr 1fr; column-gap: 48px; }
|
||||||
|
.cg-media-grid li { display: flex; align-items: center; gap: 14px; padding: 13px 2px; border-bottom: 1px dashed var(--cg-border); min-width: 0; }
|
||||||
|
.cg-media-grid li:nth-last-child(-n+2) { border-bottom: 0; }
|
||||||
|
.cg-source-badge {
|
||||||
|
flex: none; min-width: 88px; max-width: 140px; text-align: center;
|
||||||
|
font-size: 13px; font-weight: 600; color: #fff; padding: 6px 10px;
|
||||||
|
background: linear-gradient(135deg, var(--cg-accent), #ff9f4d); border-radius: 8px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
box-shadow: 0 3px 8px rgba(255, 122, 26, .30);
|
||||||
|
}
|
||||||
|
.cg-media-title { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cg-media-title:hover { color: var(--cg-primary); }
|
||||||
|
|
||||||
|
/* ===== 形态三:默认卡(featured + 行式列表) ===== */
|
||||||
|
.cg-block { padding: 40px 0 0; }
|
||||||
|
.cg-block-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;
|
||||||
|
}
|
||||||
|
.cg-block-cols { display: grid; grid-template-columns: 5fr 6fr; gap: 30px; }
|
||||||
|
.cg-block-featured { display: flex; gap: 16px; min-width: 0; }
|
||||||
|
.cg-feat-cover { flex: none; width: 200px; border-radius: 10px; overflow: hidden; }
|
||||||
|
.cg-feat-cover img { width: 100%; height: 140px; object-fit: cover; transition: transform .3s; }
|
||||||
|
.cg-block-featured:hover .cg-feat-cover img { transform: scale(1.05); }
|
||||||
|
.cg-feat-body { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
|
.cg-feat-title { font-size: 16px; font-weight: 600; color: var(--cg-text); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.cg-block-featured:hover .cg-feat-title { color: var(--cg-primary); }
|
||||||
|
.cg-feat-summary { font-size: 13px; color: var(--cg-text-light); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.cg-feat-meta { margin-top: auto; font-size: 13px; color: var(--cg-text-light); }
|
||||||
|
.cg-block-list li { display: flex; align-items: center; gap: 12px; padding: 8px 2px; border-bottom: 1px dashed var(--cg-border); }
|
||||||
|
.cg-block-list li:last-child { border-bottom: 0; }
|
||||||
|
.cg-block-list li a { flex: 1; min-width: 0; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.cg-block-list li a:hover { color: var(--cg-primary); }
|
||||||
|
.cg-block-list time { flex: none; font-size: 13px; color: var(--cg-text-light); }
|
||||||
|
|
||||||
|
/* ===== 线上展厅画廊 ===== */
|
||||||
|
.cg-gallery { padding: 52px 0 58px; }
|
||||||
|
.cg-gallery-head { text-align: center; margin-bottom: 26px; }
|
||||||
|
.cg-gallery-head h2 {
|
||||||
|
display: inline-block; position: relative; font-size: 30px; font-weight: 700;
|
||||||
|
color: var(--cg-accent); letter-spacing: 4px; padding: 0 74px;
|
||||||
|
}
|
||||||
|
.cg-gallery-head h2::before, .cg-gallery-head h2::after {
|
||||||
|
content: ""; position: absolute; top: 50%; width: 54px; height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--cg-accent));
|
||||||
|
}
|
||||||
|
.cg-gallery-head h2::before { left: 0; }
|
||||||
|
.cg-gallery-head h2::after { right: 0; transform: scaleX(-1); }
|
||||||
|
.cg-gallery-sub { color: var(--cg-text-light); font-size: 14px; letter-spacing: 2px; margin-top: 2px; text-transform: uppercase; }
|
||||||
|
.cg-gallery-stage { position: relative; overflow: hidden; border-radius: var(--cg-radius); }
|
||||||
|
.cg-gallery-track { display: flex; transition: transform .45s ease; }
|
||||||
|
.cg-gallery-item { position: relative; flex: 0 0 25%; display: block; padding: 0 8px; }
|
||||||
|
.cg-gallery-cols-3 .cg-gallery-item { flex-basis: 33.3333%; }
|
||||||
|
.cg-gallery-cols-4 .cg-gallery-item { flex-basis: 25%; }
|
||||||
|
.cg-gallery-cols-5 .cg-gallery-item { flex-basis: 20%; }
|
||||||
|
.cg-gallery-item img { width: 100%; height: 260px; object-fit: cover; border-radius: 10px; transition: transform .3s, box-shadow .3s; }
|
||||||
|
.cg-gallery-item:hover img { transform: translateY(-4px); box-shadow: 0 14px 28px rgba(10, 98, 68, .22); }
|
||||||
|
.cg-gallery-cap {
|
||||||
|
position: absolute; left: 8px; right: 8px; bottom: 0; padding: 26px 12px 9px;
|
||||||
|
color: #fff; font-size: 14px; border-radius: 0 0 10px 10px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .55));
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 页脚(个性化) ===== */
|
||||||
|
.cg-footer {
|
||||||
|
position: relative; color: rgba(255, 255, 255, .88); margin-top: 46px;
|
||||||
|
background: linear-gradient(135deg, var(--cg-primary-dark) 0%, var(--cg-primary) 78%, #35b98a 100%);
|
||||||
|
}
|
||||||
|
.cg-footer::before {
|
||||||
|
content: ""; position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background: radial-gradient(circle at 12% 20%, rgba(255, 255, 255, .10) 0, rgba(255, 255, 255, .10) 2px, transparent 3px),
|
||||||
|
radial-gradient(circle at 88% 30%, rgba(255, 255, 255, .08) 0, rgba(255, 255, 255, .08) 2px, transparent 3px);
|
||||||
|
background-size: 130px 130px, 170px 170px;
|
||||||
|
}
|
||||||
|
.cg-footer-main {
|
||||||
|
position: relative; display: grid; grid-template-columns: 1.1fr 1.6fr auto;
|
||||||
|
gap: 38px; padding-top: 40px; padding-bottom: 30px;
|
||||||
|
}
|
||||||
|
.cg-footer-logo-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
|
.cg-footer-logo { max-height: 52px; max-width: 200px; width: auto; background: rgba(255, 255, 255, .92); border-radius: 8px; padding: 4px; }
|
||||||
|
.cg-footer-site { font-size: 20px; font-weight: 700; color: #fff; }
|
||||||
|
.cg-social { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.cg-social-icon {
|
||||||
|
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; background: rgba(255, 255, 255, .16);
|
||||||
|
clip-path: polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%);
|
||||||
|
transition: background .2s, transform .2s;
|
||||||
|
}
|
||||||
|
.cg-social-icon:hover { background: var(--cg-accent); transform: translateY(-3px); }
|
||||||
|
.cg-footer-addr li { font-size: 14px; line-height: 2; }
|
||||||
|
.cg-footer-line { font-size: 14px; line-height: 2; }
|
||||||
|
.cg-footer-qr { display: flex; gap: 18px; }
|
||||||
|
.cg-qr { text-align: center; }
|
||||||
|
.cg-qr img {
|
||||||
|
width: 108px; height: 108px; object-fit: contain; background: #fff; border-radius: 8px; padding: 6px;
|
||||||
|
transition: transform .25s ease, box-shadow .25s ease; position: relative; z-index: 5;
|
||||||
|
}
|
||||||
|
.cg-qr:hover img { transform: scale(2.1); box-shadow: 0 12px 30px rgba(0, 0, 0, .35); }
|
||||||
|
.cg-qr figcaption { font-size: 13px; margin-top: 7px; color: rgba(255, 255, 255, .80); }
|
||||||
|
.cg-footer-links { position: relative; padding-bottom: 18px; }
|
||||||
|
.cg-links-group { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; padding: 6px 0; }
|
||||||
|
.cg-group-name { flex: none; font-size: 14px; font-weight: 600; color: #fff; }
|
||||||
|
.cg-links-row { display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||||||
|
.cg-links-row a { font-size: 13px; color: rgba(255, 255, 255, .82); display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.cg-links-row a:hover { color: #fff; }
|
||||||
|
.cg-link-logo { max-height: 22px; max-width: 60px; }
|
||||||
|
.cg-footer-bottom { position: relative; border-top: 1px solid rgba(255, 255, 255, .22); text-align: center; padding: 14px 16px 18px; font-size: 14px; letter-spacing: 1px; }
|
||||||
|
|
||||||
|
/* 返回顶部 */
|
||||||
|
.cg-backtop {
|
||||||
|
position: fixed; right: 26px; bottom: 90px; z-index: 150;
|
||||||
|
width: 46px; height: 46px; border: 0; border-radius: 50%;
|
||||||
|
background: var(--cg-primary); color: #fff; font-size: 20px;
|
||||||
|
box-shadow: 0 8px 20px rgba(10, 98, 68, .35);
|
||||||
|
opacity: 0; visibility: hidden; transform: translateY(14px);
|
||||||
|
transition: all .25s ease;
|
||||||
|
}
|
||||||
|
.cg-backtop.visible { opacity: 1; visibility: visible; transform: translateY(0); }
|
||||||
|
.cg-backtop:hover { background: var(--cg-accent); }
|
||||||
|
|
||||||
|
/* ===== 内页(列表/单页/详情共用) ===== */
|
||||||
|
.cg-list-main { padding: 26px 16px 46px; }
|
||||||
|
.cg-page-cols { display: grid; grid-template-columns: 232px 1fr; gap: 22px; align-items: start; }
|
||||||
|
.cg-side-nav { background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: var(--cg-radius); overflow: hidden; }
|
||||||
|
.cg-side-nav-head {
|
||||||
|
background: linear-gradient(135deg, var(--cg-primary-dark), var(--cg-primary));
|
||||||
|
color: #fff; font-size: 17px; font-weight: 600; padding: 13px 18px; letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.cg-side-nav-item a { display: block; padding: 11px 18px; font-size: 14px; border-left: 3px solid transparent; color: var(--cg-text); }
|
||||||
|
.cg-side-nav-item a:hover { background: color-mix(in srgb, var(--cg-primary) 7%, #fff); color: var(--cg-primary); }
|
||||||
|
.cg-side-nav-item.active a { border-left-color: var(--cg-accent); background: color-mix(in srgb, var(--cg-primary) 9%, #fff); color: var(--cg-primary); font-weight: 600; }
|
||||||
|
.cg-page-card { background: var(--cg-card-bg); border: 1px solid var(--cg-border); border-radius: var(--cg-radius); padding: 24px 28px 30px; min-width: 0; }
|
||||||
|
.cg-breadcrumb { font-size: 13px; color: var(--cg-text-light); margin-bottom: 8px; }
|
||||||
|
.cg-breadcrumb a:hover { color: var(--cg-primary); }
|
||||||
|
.cg-crumb-sep { margin: 0 8px; }
|
||||||
|
.cg-current { color: var(--cg-primary); font-weight: 600; }
|
||||||
|
.cg-page-title { font-size: 24px; font-weight: 700; color: var(--cg-primary-dark); margin: 6px 0 16px; padding-bottom: 12px; border-bottom: 2px solid var(--cg-border); position: relative; }
|
||||||
|
.cg-page-title::after { content: ""; position: absolute; left: 0; bottom: -2px; width: 64px; height: 2px; background: var(--cg-accent); }
|
||||||
|
|
||||||
|
.cg-article-list li { border-bottom: 1px dashed var(--cg-border); }
|
||||||
|
.cg-article-list li:last-child { border-bottom: 0; }
|
||||||
|
.cg-article-item { display: flex; gap: 18px; padding: 16px 2px; }
|
||||||
|
.cg-item-cover { flex: none; width: 190px; border-radius: 10px; overflow: hidden; }
|
||||||
|
.cg-item-cover img { width: 100%; height: 122px; object-fit: cover; transition: transform .3s; }
|
||||||
|
.cg-article-item:hover .cg-item-cover img { transform: scale(1.05); }
|
||||||
|
.cg-item-body { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.cg-item-title a { font-size: 16px; font-weight: 600; display: block; }
|
||||||
|
.cg-item-title a:hover { color: var(--cg-primary); }
|
||||||
|
.cg-item-summary { font-size: 13px; color: var(--cg-text-light); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.cg-item-meta { margin-top: auto; display: flex; align-items: center; gap: 16px; font-size: 13px; color: var(--cg-text-light); }
|
||||||
|
|
||||||
|
.cg-pagination { display: flex; justify-content: center; gap: 8px; margin-top: 22px; flex-wrap: wrap; }
|
||||||
|
.cg-pagination a { min-width: 34px; height: 34px; padding: 0 10px; display: inline-flex; align-items: center; justify-content: center; font-size: 14px; border: 1px solid var(--cg-border); border-radius: 17px; color: var(--cg-text); background: var(--cg-card-bg); }
|
||||||
|
.cg-pagination a:hover { border-color: var(--cg-primary); color: var(--cg-primary); }
|
||||||
|
.cg-pagination a.active { background: var(--cg-primary); border-color: var(--cg-primary); color: #fff; }
|
||||||
|
.cg-pagination a.disabled { opacity: .45; pointer-events: none; }
|
||||||
|
|
||||||
|
.cg-article-title { font-size: 24px; font-weight: 700; color: var(--cg-text); line-height: 1.5; margin: 10px 0 8px; }
|
||||||
|
.cg-article-meta { display: flex; flex-wrap: wrap; gap: 6px 20px; font-size: 13px; color: var(--cg-text-light); padding-bottom: 16px; border-bottom: 1px solid var(--cg-border); }
|
||||||
|
.cg-article-content { padding-top: 18px; font-size: 15px; line-height: 1.9; word-break: break-word; }
|
||||||
|
.cg-article-content img { max-width: 100%; height: auto; margin: 8px auto; border-radius: 8px; }
|
||||||
|
.cg-article-content p { margin: 0 0 12px; }
|
||||||
|
.cg-article-sibling { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 26px; }
|
||||||
|
.cg-article-sibling .prev, .cg-article-sibling .next { border: 1px solid var(--cg-border); border-radius: 10px; padding: 12px 16px; display: block; min-width: 0; }
|
||||||
|
.cg-article-sibling .next { text-align: right; }
|
||||||
|
.cg-article-sibling a:hover { border-color: var(--cg-primary); }
|
||||||
|
.cg-sib-label { display: block; font-size: 12px; color: var(--cg-text-light); margin-bottom: 4px; }
|
||||||
|
.cg-sib-title { display: block; font-size: 14px; color: var(--cg-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* ===== 空态 ===== */
|
||||||
|
.cg-empty { text-align: center; padding: 44px 0; color: var(--cg-text-light); }
|
||||||
|
.cg-empty-hint { font-size: 13px; margin-top: 6px; opacity: .8; }
|
||||||
|
|
||||||
|
/* ===== 响应式 ===== */
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.cg-gallery-item, .cg-gallery-cols-3 .cg-gallery-item, .cg-gallery-cols-4 .cg-gallery-item, .cg-gallery-cols-5 .cg-gallery-item { flex-basis: 50%; }
|
||||||
|
.cg-gallery-item img { height: 210px; }
|
||||||
|
.cg-footer-main { grid-template-columns: 1fr 1fr; }
|
||||||
|
.cg-footer-qr { grid-column: 1 / -1; }
|
||||||
|
.cg-block-cols { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cg-site-name { font-size: 20px; }
|
||||||
|
.cg-site-search input { width: 150px; }
|
||||||
|
.cg-nav > ul > li > a { padding: 11px 13px; font-size: 14px; }
|
||||||
|
.cg-banner-slide img { aspect-ratio: 16 / 9; }
|
||||||
|
.cg-banner-cap { font-size: 15px; padding: 30px 5% 14px; }
|
||||||
|
.cg-ticker-label { display: none; }
|
||||||
|
.cg-news-grid, .cg-media-grid { grid-template-columns: 1fr; }
|
||||||
|
.cg-news-grid li:nth-last-child(-n+2), .cg-media-grid li:nth-last-child(-n+2) { border-bottom: 1px dashed var(--cg-border); }
|
||||||
|
.cg-news-grid li:last-child, .cg-media-grid li:last-child { border-bottom: 0; }
|
||||||
|
.cg-newshot-card, .cg-media-card, .cg-block-card { padding: 20px 16px; }
|
||||||
|
.cg-sec-head h2 { font-size: 21px; }
|
||||||
|
.cg-page-cols { grid-template-columns: 1fr; }
|
||||||
|
.cg-article-item { flex-direction: column; }
|
||||||
|
.cg-item-cover { width: 100%; }
|
||||||
|
.cg-item-cover img { height: 170px; }
|
||||||
|
.cg-article-sibling { grid-template-columns: 1fr; }
|
||||||
|
.cg-footer-main { grid-template-columns: 1fr; gap: 24px; }
|
||||||
|
.cg-backtop { right: 14px; bottom: 70px; width: 40px; height: 40px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cg-gallery-item, .cg-gallery-cols-3 .cg-gallery-item, .cg-gallery-cols-4 .cg-gallery-item, .cg-gallery-cols-5 .cg-gallery-item { flex-basis: 100%; }
|
||||||
|
.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; }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 936 B |
@@ -0,0 +1,117 @@
|
|||||||
|
/* campus-glow 皮肤包脚本:顶条日期 + 首页轮播 + 线上展厅画廊翻页 + 返回顶部,无任何外部依赖 */
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 首页轮播 */
|
||||||
|
(function () {
|
||||||
|
var root = document.querySelector('.cg-banner');
|
||||||
|
if (!root) return;
|
||||||
|
var slides = root.querySelectorAll('.cg-banner-slide');
|
||||||
|
var dots = root.querySelectorAll('.cg-banner-dots button');
|
||||||
|
var prevBtn = root.querySelector('.cg-banner-arrow.prev');
|
||||||
|
var nextBtn = root.querySelector('.cg-banner-arrow.next');
|
||||||
|
if (!slides.length) 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);
|
||||||
|
start();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* 线上展厅画廊:按页翻动(每页 = 当前列数),到尾页回卷 */
|
||||||
|
(function () {
|
||||||
|
var stage = document.querySelector('.cg-gallery-stage');
|
||||||
|
if (!stage) return;
|
||||||
|
var track = stage.querySelector('.cg-gallery-track');
|
||||||
|
var items = track ? track.children : [];
|
||||||
|
var prevBtn = stage.querySelector('.cg-gallery-arrow.prev');
|
||||||
|
var nextBtn = stage.querySelector('.cg-gallery-arrow.next');
|
||||||
|
if (!track || items.length < 1) return;
|
||||||
|
var page = 0;
|
||||||
|
var timer = null;
|
||||||
|
var INTERVAL = 4500;
|
||||||
|
|
||||||
|
function perView() {
|
||||||
|
var w = window.innerWidth;
|
||||||
|
if (w <= 640) return 1;
|
||||||
|
if (w <= 992) return 2;
|
||||||
|
return parseInt(stage.getAttribute('data-cols') || '4', 10) || 4;
|
||||||
|
}
|
||||||
|
function pages() {
|
||||||
|
return Math.max(1, Math.ceil(items.length / perView()));
|
||||||
|
}
|
||||||
|
function update() {
|
||||||
|
if (page >= pages()) page = 0;
|
||||||
|
if (page < 0) page = pages() - 1;
|
||||||
|
track.style.transform = 'translateX(-' + (page * 100) + '%)';
|
||||||
|
}
|
||||||
|
function start() {
|
||||||
|
stop();
|
||||||
|
if (pages() > 1) {
|
||||||
|
timer = window.setInterval(function () { page++; update(); }, INTERVAL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function go(delta) { page += delta; update(); start(); }
|
||||||
|
function stop() {
|
||||||
|
if (timer) { window.clearInterval(timer); timer = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevBtn) prevBtn.addEventListener('click', function () { go(-1); });
|
||||||
|
if (nextBtn) nextBtn.addEventListener('click', function () { go(1); });
|
||||||
|
stage.addEventListener('mouseenter', stop);
|
||||||
|
stage.addEventListener('mouseleave', start);
|
||||||
|
window.addEventListener('resize', function () { page = 0; update(); });
|
||||||
|
|
||||||
|
update();
|
||||||
|
start();
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* 返回顶部:滚动一屏后出现,点击平滑回顶 */
|
||||||
|
(function () {
|
||||||
|
var btn = document.querySelector('.cg-backtop');
|
||||||
|
if (!btn) return;
|
||||||
|
function toggle() {
|
||||||
|
if (window.scrollY > window.innerHeight * 0.6) {
|
||||||
|
btn.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', toggle, { passive: true });
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
toggle();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 124 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="cg-list-main cg-container" id="main-content">
|
||||||
|
<div class="cg-page-cols">
|
||||||
|
<!-- 左侧栏目导航(docs/11 侧栏契约):模块有二级子栏目时展示"模块名 + 全部兄弟" -->
|
||||||
|
<aside class="cg-side-nav" th:if="${sideNav != null}" aria-label="栏目导航">
|
||||||
|
<div class="cg-side-nav-head" th:text="${sideNav.top.name}">模块名</div>
|
||||||
|
<ul class="cg-side-nav-list">
|
||||||
|
<li class="cg-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="cg-page-card">
|
||||||
|
<nav class="cg-breadcrumb" aria-label="面包屑">
|
||||||
|
<a th:href="${url.home()}">首页</a>
|
||||||
|
<th:block th:each="node : ${categoryPath}">
|
||||||
|
<span class="cg-crumb-sep" aria-hidden="true">/</span>
|
||||||
|
<a class="cg-crumb-parent" th:href="${url.category(node.id)}" th:text="${node.name}">栏目</a>
|
||||||
|
</th:block>
|
||||||
|
<span class="cg-crumb-sep" th:if="${currentCategoryId != null}" aria-hidden="true">/</span>
|
||||||
|
<span class="cg-current">正文</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article class="cg-article">
|
||||||
|
<h1 class="cg-article-title" th:text="${article.title}">文章标题</h1>
|
||||||
|
<div class="cg-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="cg-article-content" th:utext="${article.content}">正文</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<nav class="cg-article-sibling" aria-label="上下篇导航">
|
||||||
|
<a th:if="${prevArticle != null}" class="prev"
|
||||||
|
th:href="${url.article(prevArticle.id)}">
|
||||||
|
<span class="cg-sib-label">上一篇</span>
|
||||||
|
<span class="cg-sib-title" th:text="${prevArticle.title}">上一篇标题</span>
|
||||||
|
</a>
|
||||||
|
<span class="cg-sib-none prev" th:if="${prevArticle == null}">
|
||||||
|
<span class="cg-sib-label">上一篇</span>
|
||||||
|
<span class="cg-sib-title">已经是最早一篇</span>
|
||||||
|
</span>
|
||||||
|
<a th:if="${nextArticle != null}" class="next"
|
||||||
|
th:href="${url.article(nextArticle.id)}">
|
||||||
|
<span class="cg-sib-label">下一篇</span>
|
||||||
|
<span class="cg-sib-title" th:text="${nextArticle.title}">下一篇标题</span>
|
||||||
|
</a>
|
||||||
|
<span class="cg-sib-none next" th:if="${nextArticle == null}">
|
||||||
|
<span class="cg-sib-label">下一篇</span>
|
||||||
|
<span class="cg-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,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<!-- 公共页脚(docs/12 个性化页脚):品牌区(logo+站名+社媒图标阵列) + 多校区地址 + 二维码(hover 放大)
|
||||||
|
+ 联系/版权/备案 + 友链分组 + 底条校名 + 返回顶部。
|
||||||
|
基础数据契约与 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="cg-footer" th:fragment="footer">
|
||||||
|
<div class="cg-container cg-footer-main">
|
||||||
|
<!-- 品牌区 -->
|
||||||
|
<div class="cg-footer-brand">
|
||||||
|
<div class="cg-footer-logo-row">
|
||||||
|
<img th:if="${site.config['logo'] != null}" class="cg-footer-logo"
|
||||||
|
th:src="${url.upload(site.config['logo'])}" th:alt="${site.name}">
|
||||||
|
<span class="cg-footer-site" th:text="${site.name}">站点名称</span>
|
||||||
|
</div>
|
||||||
|
<div class="cg-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="cg-social-icon"
|
||||||
|
th:href="${site.config['social_wechat']}" target="_blank" rel="noopener" aria-label="微信公众号" th:title="微信公众号">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" 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="cg-social-icon"
|
||||||
|
th:href="${site.config['social_weibo']}" target="_blank" rel="noopener" aria-label="微博" th:title="微博">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" 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="cg-social-icon"
|
||||||
|
th:href="${site.config['social_douyin']}" target="_blank" rel="noopener" aria-label="抖音" th:title="抖音">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" 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="cg-social-icon"
|
||||||
|
th:href="${site.config['social_bilibili']}" target="_blank" rel="noopener" aria-label="哔哩哔哩" th:title="哔哩哔哩">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" 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>
|
||||||
|
|
||||||
|
<!-- 地址/联系区 -->
|
||||||
|
<div class="cg-footer-info">
|
||||||
|
<ul class="cg-footer-addr" th:if="${!#lists.isEmpty(site.footerAddressLines)}">
|
||||||
|
<li th:each="line : ${site.footerAddressLines}" th:text="${line}">校区地址</li>
|
||||||
|
</ul>
|
||||||
|
<p class="cg-footer-line" th:if="${site.config['contact'] != null}">
|
||||||
|
<span th:text="${site.config['contact']}">联系方式</span>
|
||||||
|
</p>
|
||||||
|
<p class="cg-footer-line" th:if="${site.config['copyright'] != null}">
|
||||||
|
<span th:text="${site.config['copyright']}">版权信息</span>
|
||||||
|
</p>
|
||||||
|
<p class="cg-footer-line" th:if="${site.config['beian'] != null}">
|
||||||
|
<span th:text="${site.config['beian']}">备案号</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码区(hover 放大预览) -->
|
||||||
|
<div class="cg-footer-qr" th:if="${site.config['qr_wechat'] != null or site.config['qr_weibo'] != null}">
|
||||||
|
<figure class="cg-qr" th:if="${site.config['qr_wechat'] != null}">
|
||||||
|
<img th:src="${url.upload(site.config['qr_wechat'])}" alt="微信二维码">
|
||||||
|
<figcaption>微信扫码</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="cg-qr" th:if="${site.config['qr_weibo'] != null}">
|
||||||
|
<img th:src="${url.upload(site.config['qr_weibo'])}" alt="微博二维码">
|
||||||
|
<figcaption>微博扫码</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 友链分组(无分组整栏不渲染) -->
|
||||||
|
<div class="cg-container cg-footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||||
|
<div class="cg-links-group" th:each="group : ${site.linkGroups}"
|
||||||
|
th:if="${!#lists.isEmpty(group.links)}">
|
||||||
|
<span class="cg-group-name" th:text="${group.groupName}">分组</span>
|
||||||
|
<p class="cg-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="cg-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 class="cg-footer-bottom">
|
||||||
|
<p th:if="${site.schName != null and !#strings.isEmpty(site.schName)}"
|
||||||
|
th:text="${site.schName}">学校名称</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="cg-backtop" type="button" aria-label="返回顶部" th:title="返回顶部">↑</button>
|
||||||
|
</footer>
|
||||||
|
</html>
|
||||||
+1
-5
@@ -1,6 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
|
||||||
<!-- 公共 head 片段:SEO(title/description/canonical)+ 主题资产(方案 §8.3/§8.6) -->
|
<!-- 公共 head 片段:契约与 default 一致(SEO + 主题资产 + 变量/自定义样式注入),仅换皮 -->
|
||||||
<head th:fragment="head">
|
<head th:fragment="head">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -8,16 +8,12 @@
|
|||||||
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
<meta name="description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}"
|
||||||
th:content="${seoDescription}">
|
th:content="${seoDescription}">
|
||||||
<link rel="canonical" th:if="${canonical != null}" th:href="${canonical}">
|
<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: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:description" th:if="${seoDescription != null and !#strings.isEmpty(seoDescription)}" th:content="${seoDescription}">
|
||||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<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>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
|
||||||
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
</head>
|
</head>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user