Files
YschcmsFront/src/views/cms/homeLayout/homeLayout.ts
T

681 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
* 与后端契约对齐: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";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致;docs/20 D 新增后 12 种) */
export type BlockType =
| "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar"
// docs/20 D-1~D-9
| "stats_band" | "featured_topics" | "video_tour" | "events" | "people" | "badges";
/** quick_links 单个入口(v2 手册 §2.8;D-2 加可选 icon 内置图标标识符) */
export interface QuickLinkItem {
name: string;
url: string;
icon?: string;
}
/** stats_band 单个统计项(D-1:数字计数带) */
export interface StatItem {
label: string;
value: string;
unit?: string;
icon?: string;
}
/** featured_topics 单张专题卡(D-3) */
export interface TopicItem {
title: string;
description?: string;
image: string;
ctaText?: string;
ctaUrl?: string;
}
/** badges 单个徽章(D-9) */
export interface BadgeItem {
label: string;
desc?: string;
image: string;
}
/** article_list 展示形态(与后端渲染端 style 分支及主题 fragment 对应) */
export const LIST_STYLES: { value: string; label: string }[] = [
{ value: "card", label: "默认卡(头条+列表)" },
{ value: "news_grid", label: "双栏新闻卡" },
{ value: "media_focus", label: "媒体聚焦" },
{ value: "compact", label: "纯文字列表" },
{ value: "image_wall", label: "图片墙(有封面文章)" },
];
export function styleLabel(style: string | undefined): string {
return LIST_STYLES.find(s => s.value === style)?.label || "默认卡(头条+列表)";
}
/** 布局区块(编辑器内存形态:含 visible 运行时键;序列化时仅落业务键) */
export interface LayoutBlock {
id: string;
type: BlockType;
/** false = 保序下线(渲染端跳过) */
visible: boolean;
title?: string;
/** gallery 英文副题 */
subtitle?: string;
/** article_list / ticker(source=category):数据栏目(可多选,渲染端聚合子孙) */
categoryIds?: number[];
/** article_list 展示形态,缺省 card */
style?: string;
/** 条数(article_list 缺省 8、ticker 缺省 6;gallery 不设 = 全部) */
size?: number;
/** article_list news_grid 背景大图开关(取站点配置 news_bg_image 旧键) */
background?: boolean;
/** ticker 数据源:top=全站置顶(缺省)| category=指定栏目最新 */
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;
// ===== docs/20 D 组件库新增字段 =====
/** gallery 布局(D-8):grid 规整网格(缺省)| masonry 瀑布流 */
layout?: string;
/** quick_links 统一图标形状随主题参数,不落区块 */
/** stats_band 统计项(D-1,1~8 项) */
stats?: StatItem[];
/** featured_topics 专题卡(D-3,编辑器模型;序列化键为 items,与后端契约对齐) */
topicItems?: TopicItem[];
/** video_tour(D-5) */
cover?: string;
/** people 数据栏目(D-7,可空 = 全部人物) */
categoryId?: number;
/** badges 徽章(D-9,1~8 项) */
badgeItems?: BadgeItem[];
}
export interface HomeLayout {
version: number;
blocks: LayoutBlock[];
}
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8 + docs/20 D) */
export const BLOCK_TYPES_ALLOWED: BlockType[] = [
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
"stats_band", "featured_topics", "video_tour", "events", "people", "badges",
];
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"];
// ===== docs/20 D 组件库约束(与后端 SiteService 同契约) =====
export const STATS_MAX = 8;
export const TOPICS_MAX = 6;
export const BADGES_MAX = 8;
export const GALLERY_LAYOUTS = ["grid", "masonry"];
/** 内置图标标识符(D-2:与门户骨架 icon fragment 同 key 集) */
export const ICON_KEY_PATTERN = /^[a-z0-9-]{1,30}$/;
export const ASSET_URL_PATTERN = /^(\/.+|https?:\/\/\S+)$/;
/** 入口/跳转地址: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);
}
function asObject(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
/**
* configJson → 布局(容错解析):无键 / 非法 JSON / home_layout 非对象 / blocks 非数组 → null(=回落现状)。
* 单块收敛为已知字段(未知键丢弃);type 不在白名单的块跳过。
*/
export function parseHomeLayout(configJson: string | undefined | null): HomeLayout | null {
if (!configJson || !configJson.trim()) {
return null;
}
let root: Record<string, unknown> | null = null;
try {
root = asObject(JSON.parse(configJson));
} catch {
return null;
}
if (!root) {
return null;
}
const hl = asObject(root["home_layout"]);
if (!hl || !Array.isArray(hl["blocks"])) {
return null;
}
const blocks: LayoutBlock[] = [];
for (const raw of hl["blocks"] as unknown[]) {
const obj = asObject(raw);
const type = typeof obj?.["type"] === "string" ? (obj["type"] as string) : "";
if (!BLOCK_TYPES_ALLOWED.includes(type as BlockType)) {
continue;
}
blocks.push(normalizeBlock(obj as Record<string, unknown>, type as BlockType));
}
return { version: typeof hl["version"] === "number" ? hl["version"] : HOME_LAYOUT_VERSION, blocks };
}
function toIdList(v: unknown): number[] | undefined {
if (!Array.isArray(v)) {
return undefined;
}
const ids = v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
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;
}
const item: QuickLinkItem = { name, url };
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) {
item.icon = obj["icon"] as string;
}
items.push(item);
}
return items.length ? items : undefined;
}
/** stats_band 统计项容错还原(D-1) */
function toStatItems(v: unknown): StatItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: StatItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const value = typeof obj?.["value"] === "string" ? (obj["value"] as string).trim() : "";
if (!label || !value) continue;
const item: StatItem = { label, value };
if (typeof obj?.["unit"] === "string" && (obj["unit"] as string).trim()) item.unit = (obj["unit"] as string).trim();
if (typeof obj?.["icon"] === "string" && ICON_KEY_PATTERN.test(obj["icon"] as string)) item.icon = obj["icon"] as string;
items.push(item);
}
return items.length ? items : undefined;
}
/** featured_topics 专题卡容错还原(D-3) */
function toTopicItems(v: unknown): TopicItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: TopicItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const title = typeof obj?.["title"] === "string" ? (obj["title"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!title || !image) continue;
const item: TopicItem = { title, image };
if (typeof obj?.["description"] === "string" && (obj["description"] as string).trim()) item.description = (obj["description"] as string).trim();
if (typeof obj?.["ctaText"] === "string" && (obj["ctaText"] as string).trim()) item.ctaText = (obj["ctaText"] as string).trim();
if (typeof obj?.["ctaUrl"] === "string" && (obj["ctaUrl"] as string).trim()) item.ctaUrl = (obj["ctaUrl"] as string).trim();
items.push(item);
}
return items.length ? items : undefined;
}
/** badges 徽章容错还原(D-9,序列化键 badgeItems 防与 featured_topics.items 语义混淆) */
function toBadgeItems(v: unknown): BadgeItem[] | undefined {
if (!Array.isArray(v)) return undefined;
const items: BadgeItem[] = [];
for (const raw of v as unknown[]) {
const obj = asObject(raw);
const label = typeof obj?.["label"] === "string" ? (obj["label"] as string).trim() : "";
const image = typeof obj?.["image"] === "string" ? (obj["image"] as string).trim() : "";
if (!label || !image) continue;
const item: BadgeItem = { label, image };
if (typeof obj?.["desc"] === "string" && (obj["desc"] as string).trim()) item.desc = (obj["desc"] as string).trim();
items.push(item);
}
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 = {
id: typeof obj["id"] === "string" && obj["id"] ? obj["id"] : genBlockId(),
type,
visible: obj["visible"] !== false,
};
if (typeof obj["title"] === "string") block.title = obj["title"];
if (typeof obj["subtitle"] === "string") block.subtitle = obj["subtitle"];
if (typeof obj["style"] === "string") block.style = obj["style"];
if (size !== undefined) block.size = size;
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"];
}
// ===== docs/20 D =====
if (typeof obj["layout"] === "string") block.layout = obj["layout"];
if (typeof obj["cover"] === "string") block.cover = obj["cover"];
if (typeof obj["categoryId"] === "number" && Number.isFinite(obj["categoryId"]) && (obj["categoryId"] as number) > 0) {
block.categoryId = obj["categoryId"] as number;
}
if (type === "stats_band") {
const stats = toStatItems(obj["stats"]);
if (stats) block.stats = stats;
}
if (type === "featured_topics") {
const items = toTopicItems(obj["items"]);
if (items) block.topicItems = items;
}
if (type === "badges") {
const items = toBadgeItems(obj["badgeItems"]);
if (items) block.badgeItems = items;
}
const ids = toIdList(obj["categoryIds"]);
if (ids) block.categoryIds = ids;
return block;
}
/** 单块 → 序列化对象:只写与类型相关的业务键(visible 仅 false 时写,size 仅显式设置时写) */
export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
const out: Record<string, unknown> = { id: block.id, type: block.type };
if (block.visible === false) {
out["visible"] = false;
}
const title = (block.title || "").trim();
if (title) {
out["title"] = title;
}
if (block.type === "article_list") {
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.style && block.style !== "card") {
out["style"] = block.style;
}
if (block.size != null) {
out["size"] = block.size;
}
if (block.background) {
out["background"] = true;
}
} else if (block.type === "ticker") {
if (block.source && block.source !== "top") {
out["source"] = block.source;
}
if (block.categoryIds && block.categoryIds.length) {
out["categoryIds"] = block.categoryIds;
}
if (block.size != null) {
out["size"] = block.size;
}
} else if (block.type === "gallery") {
const subtitle = (block.subtitle || "").trim();
if (subtitle) {
out["subtitle"] = subtitle;
}
if (block.layout === "masonry") {
out["layout"] = "masonry";
}
} else if (block.type === "links") {
const group = (block.group || "").trim();
if (group) {
out["group"] = group;
}
} else if (block.type === "quick_links") {
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
const items = (block.items || [])
.map(i => {
const row: Record<string, string> = { name: (i.name || "").trim(), url: (i.url || "").trim() };
if (i.icon && ICON_KEY_PATTERN.test(i.icon)) {
row["icon"] = i.icon;
}
return row;
})
.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";
}
} else if (block.type === "stats_band") {
const stats = (block.stats || []).filter(st => st.label.trim() && st.value.trim());
if (stats.length) {
out["stats"] = stats;
}
} else if (block.type === "featured_topics") {
const items = (block.topicItems || []).filter(t => t.title.trim() && t.image.trim());
if (items.length) {
out["items"] = items;
}
} else if (block.type === "video_tour") {
const cover = (block.cover || "").trim();
const url = (block.url || "").trim();
if (cover) out["cover"] = cover;
if (url) out["url"] = url;
} else if (block.type === "people") {
if (block.categoryId) {
out["categoryId"] = block.categoryId;
}
} else if (block.type === "badges") {
const items = (block.badgeItems || []).filter(b => b.label.trim() && b.image.trim());
if (items.length) {
out["badgeItems"] = items;
}
}
return out;
}
/**
* 布局写回 configJson(仅增删改 home_layout 键,其余键原样保留)。
* layout=null = 清除自定义布局(删除键,回到回落态);空 blocks = 显式空首页(写空数组,不删键)。
*/
export function serializeHomeLayout(configJson: string | undefined | null, layout: HomeLayout | null): string {
let config: Record<string, unknown> = {};
if (configJson && configJson.trim()) {
try {
const parsed = JSON.parse(configJson);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
config = { ...parsed };
}
} catch {
// 非法 JSON 按空配置处理(与后端 ExtJsonUtils.parse 语义一致)
}
}
if (!layout) {
delete config["home_layout"];
} else {
config["home_layout"] = {
version: layout.version || HOME_LAYOUT_VERSION,
blocks: layout.blocks.map(cleanBlock),
};
}
return JSON.stringify(config);
}
/** 校验返回错误消息列表(空数组=通过;与后端 validateHomeLayout 同语义) */
export function validateHomeLayout(layout: HomeLayout | null): string[] {
const errors: string[] = [];
if (!layout) {
return errors;
}
if (layout.blocks.length > MAX_BLOCKS) {
errors.push(`区块数量不能超过 ${MAX_BLOCKS} 个`);
}
layout.blocks.forEach((b, i) => {
const no = `第 ${i + 1} 个区块(${b.title || b.type})`;
if (!BLOCK_TYPES_ALLOWED.includes(b.type)) {
errors.push(`${no}:类型不合法`);
}
if ((b.title || "").trim().length > TITLE_MAX || (b.subtitle || "").trim().length > TITLE_MAX) {
errors.push(`${no}:标题/副题不能超过 ${TITLE_MAX} 字`);
}
if (b.size != null && (b.size < SIZE_MIN || b.size > SIZE_MAX)) {
errors.push(`${no}:条数须在 ${SIZE_MIN}~${SIZE_MAX} 之间`);
}
if (b.type === "article_list" && !(b.categoryIds && b.categoryIds.length)) {
errors.push(`${no}:请选择数据栏目`);
}
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`);
}
}
// ===== docs/20 D 组件校验(与后端 SiteService 同语义) =====
if (b.type === "gallery" && b.layout != null && !GALLERY_LAYOUTS.includes(b.layout)) {
errors.push(`${no}:画廊布局只能是 grid 或 masonry`);
}
if (b.type === "stats_band") {
const stats = b.stats || [];
if (!stats.length) {
errors.push(`${no}:请至少填写 1 个统计项`);
} else if (stats.length > STATS_MAX) {
errors.push(`${no}:统计项不能超过 ${STATS_MAX} 个`);
}
stats.forEach(st => {
const label = (st.label || "").trim();
const value = (st.value || "").trim();
if (!label) {
errors.push(`${no}:统计项名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:统计项名称不能超过 20 字`);
}
if (!value) {
errors.push(`${no}:统计项数值不能为空`);
} else if (value.length > 10) {
errors.push(`${no}:统计项数值不能超过 10 字`);
}
if ((st.unit || "").length > 10) {
errors.push(`${no}:统计项单位不能超过 10 字`);
}
if (st.icon && !ICON_KEY_PATTERN.test(st.icon)) {
errors.push(`${no}:统计项图标标识不合法`);
}
});
}
if (b.type === "featured_topics") {
const items = b.topicItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 张专题卡`);
} else if (items.length > TOPICS_MAX) {
errors.push(`${no}:专题卡不能超过 ${TOPICS_MAX} 张`);
}
items.forEach(t => {
const title = (t.title || "").trim();
const image = (t.image || "").trim();
if (!title) {
errors.push(`${no}:专题卡标题不能为空`);
} else if (title.length > 50) {
errors.push(`${no}:专题卡标题不能超过 50 字`);
}
if ((t.description || "").length > 200) {
errors.push(`${no}:专题卡描述不能超过 200 字`);
}
if (!image) {
errors.push(`${no}:专题卡必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:专题卡图片须为上传路径(/ 开头)或 http(s) 地址`);
}
if ((t.ctaText || "").length > 20) {
errors.push(`${no}:按钮文字不能超过 20 字`);
}
const ctaUrl = (t.ctaUrl || "").trim();
if (ctaUrl && !LINK_URL_PATTERN.test(ctaUrl)) {
errors.push(`${no}:按钮地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
});
}
if (b.type === "video_tour") {
const cover = (b.cover || "").trim();
const url = (b.url || "").trim();
if (!cover) {
errors.push(`${no}:请上传视频封面图`);
} else if (!ASSET_URL_PATTERN.test(cover)) {
errors.push(`${no}:封面图须为上传路径(/ 开头)或 http(s) 地址`);
}
if (!url) {
errors.push(`${no}:请填写视频/VR 页面地址`);
} else if (!LINK_URL_PATTERN.test(url)) {
errors.push(`${no}:视频地址须为 http(s):// 开头或站内路径(/ 开头)`);
}
}
if (b.type === "badges") {
const items = b.badgeItems || [];
if (!items.length) {
errors.push(`${no}:请至少填写 1 个徽章`);
} else if (items.length > BADGES_MAX) {
errors.push(`${no}:徽章不能超过 ${BADGES_MAX} 个`);
}
items.forEach(bd => {
const label = (bd.label || "").trim();
const image = (bd.image || "").trim();
if (!label) {
errors.push(`${no}:徽章名称不能为空`);
} else if (label.length > 20) {
errors.push(`${no}:徽章名称不能超过 20 字`);
}
if ((bd.desc || "").length > 50) {
errors.push(`${no}:徽章说明不能超过 50 字`);
}
if (!image) {
errors.push(`${no}:徽章必须上传图片`);
} else if (!ASSET_URL_PATTERN.test(image)) {
errors.push(`${no}:徽章图片须为上传路径(/ 开头)或 http(s) 地址`);
}
});
}
});
return errors;
}
/** 树展开为可勾选的扁平列表(仅列表型栏目可作数据源;外链/单页排除) */
export interface FlatCategory {
id: number;
name: string;
depth: number;
}
export function flattenListCategories(tree: CategoryTreeNode[], depth = 0, out: FlatCategory[] = []): FlatCategory[] {
(tree || []).forEach(node => {
if (node.type === 1) {
out.push({ id: node.id as number, name: node.name, depth });
}
if (node.children && node.children.length) {
flattenListCategories(node.children, depth + 1, out);
}
});
return out;
}
/** 树内全部栏目 id(布局数据源合法性参考) */
export function allCategoryIds(tree: CategoryTreeNode[]): number[] {
const ids: number[] = [];
(tree || []).forEach(node => {
if (node.id != null) {
ids.push(node.id);
}
ids.push(...allCategoryIds(node.children || []));
});
return ids;
}
/**
* 「从当前栏目一键生成」(docs/16 §4.3 首用救生索):复刻渲染端 categoryBlocks 现状算法,
* 生成与旧首页等价的初始序列(ticker → 各列表栏目区块(home_style→style、16/8 条)→ gallery),
* 用户在此基础上删改,心智从「从零搭」变成「修剪」。
*/
export function genFromCategories(tree: CategoryTreeNode[], configJson: string | undefined | null): HomeLayout {
let galleryTitle = "线上展厅";
let gallerySubtitle = "Online Exhibition Hall";
if (configJson && configJson.trim()) {
try {
const cfg = JSON.parse(configJson);
if (cfg && typeof cfg === "object") {
if (typeof cfg["gallery_title"] === "string" && cfg["gallery_title"].trim()) {
galleryTitle = cfg["gallery_title"];
}
if (typeof cfg["gallery_subtitle"] === "string" && cfg["gallery_subtitle"].trim()) {
gallerySubtitle = cfg["gallery_subtitle"];
}
}
} catch {
// 非法配置按缺省标题
}
}
const blocks: LayoutBlock[] = [
{ id: genBlockId(), type: "ticker", visible: true, title: "要闻速递", source: "top", size: 6 },
];
(tree || []).forEach(node => {
if (node.type !== 1 || node.id == null) {
return;
}
blocks.push({
id: genBlockId(),
type: "article_list",
visible: true,
title: node.name,
categoryIds: [node.id],
style: node.homeStyle || "card",
size: node.homeStyle === "news_grid" ? 16 : 8,
});
});
blocks.push({
id: genBlockId(),
type: "gallery",
visible: true,
title: galleryTitle,
subtitle: gallerySubtitle,
});
return { version: HOME_LAYOUT_VERSION, blocks };
}