feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s

This commit is contained in:
chenxing
2026-09-22 18:34:50 +08:00
parent abccca4397
commit d05272b820
14 changed files with 893 additions and 186 deletions
+111 -5
View File
@@ -1,14 +1,22 @@
/**
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字;
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、
* title/subtitle ≤100 字、quick_links items ≤12 项(name ≤20 字、url 须 http(s):// 或 / 开头)、
* notice_bar text 必填 ≤200 字、level ∈ {info,warn};
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
*/
import { CategoryTreeNode } from "@/types/cms";
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar";
/** quick_links 单个入口(v2 手册 §2.8) */
export interface QuickLinkItem {
name: string;
url: string;
}
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [
@@ -44,6 +52,14 @@ export interface LayoutBlock {
source?: string;
/** links 分组名(空=全部分组) */
group?: string;
/** quick_links 入口列表(≤12 项;name ≤20 字、url 为 http(s):// 或站内 / 路径) */
items?: QuickLinkItem[];
/** notice_bar 公告正文(必填,≤200 字) */
text?: string;
/** notice_bar 跳转地址(可选) */
url?: string;
/** notice_bar 提示级别:info=一般(缺省)| warn=醒目 */
level?: string;
}
export interface HomeLayout {
@@ -53,12 +69,20 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
];
export const SIZE_MIN = 1;
export const SIZE_MAX = 20;
export const TITLE_MAX = 100;
export const MAX_BLOCKS = 30;
export const QUICK_LINK_MAX = 12;
export const LINK_NAME_MAX = 20;
export const NOTICE_TEXT_MAX = 200;
export const NOTICE_LEVELS = ["info", "warn"];
/** 入口/跳转地址:http(s):// 绝对地址或站内 / 路径(与后端 HOME_LAYOUT_LINK_PATTERN 同语义) */
const LINK_URL_PATTERN = /^(https?:\/\/\S+|\/.+)$/;
export function genBlockId(): string {
return "b_" + Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
@@ -109,6 +133,24 @@ function toIdList(v: unknown): number[] | undefined {
return ids.length ? ids : undefined;
}
/** 入口列表容错还原:非对象项(字符串/数字/null)与非字符串 name/url 的脏行整行丢弃 */
function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const items: QuickLinkItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const name = typeof obj?.["name"] === "string" ? (obj["name"] as string).trim() : null;
const url = typeof obj?.["url"] === "string" ? (obj["url"] as string).trim() : null;
if (name === null || url === null) {
continue;
}
items.push({ name, url });
}
return items.length ? items : undefined;
}
function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBlock {
const size = typeof obj["size"] === "number" ? Math.round(obj["size"]) : undefined;
const block: LayoutBlock = {
@@ -123,6 +165,15 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
if (typeof obj["background"] === "boolean") block.background = obj["background"];
if (typeof obj["source"] === "string") block.source = obj["source"];
if (typeof obj["group"] === "string") block.group = obj["group"];
if (type === "quick_links") {
const items = toLinkItems(obj["items"]);
if (items) block.items = items;
}
if (type === "notice_bar") {
if (typeof obj["text"] === "string") block.text = obj["text"];
if (typeof obj["url"] === "string") block.url = obj["url"];
if (typeof obj["level"] === "string") block.level = obj["level"];
}
const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids;
return block;
@@ -171,6 +222,26 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
if (group) {
out["group"] = group;
}
} else if (block.type === "quick_links") {
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备)
const items = (block.items || [])
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() }))
.filter(i => i.name || i.url);
if (items.length) {
out["items"] = items;
}
} else if (block.type === "notice_bar") {
const text = (block.text || "").trim();
if (text) {
out["text"] = text;
}
const url = (block.url || "").trim();
if (url) {
out["url"] = url;
}
if (block.level === "warn") {
out["level"] = "warn";
}
}
return out;
}
@@ -228,6 +299,41 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
if (b.type === "ticker" && b.source === "category" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:指定栏目模式请选择栏目`);
}
if (b.type === "quick_links") {
const items = b.items || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个入口`);
} else if (items.length > QUICK_LINK_MAX) {
errors.push(`${no}:入口数量不能超过 ${QUICK_LINK_MAX} 个`);
}
items.forEach(item => {
const name = (item.name || "").trim();
const linkUrl = (item.url || "").trim();
if (!name) {
errors.push(`${no}:入口名称不能为空`);
} else if (name.length > LINK_NAME_MAX) {
errors.push(`${no}:入口名称不能超过 ${LINK_NAME_MAX} 字`);
}
if (!LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:入口「${name || linkUrl}」地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "notice_bar") {
const text = (b.text || "").trim();
if (!text) {
errors.push(`${no}:请填写公告正文`);
} else if (text.length > NOTICE_TEXT_MAX) {
errors.push(`${no}:公告正文不能超过 ${NOTICE_TEXT_MAX} 字`);
}
const linkUrl = (b.url || "").trim();
if (linkUrl && !LINK_URL_PATTERN.test(linkUrl)) {
errors.push(`${no}:跳转地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
if (b.level != null && !NOTICE_LEVELS.includes(b.level)) {
errors.push(`${no}:提示级别只能是 info 或 warn`);
}
}
});
return errors;
}