feat(theme): S5 学院蓝主题改造(管理端)——homeLayout编辑器/主题参数表单/站点设置/类型 + 单测
build / build (push) Successful in 2m19s
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user