/** * 首页布局纯函数模块(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 | null { return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : 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 | 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, 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, 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 { const out: Record = { 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 = { 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 = {}; 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 }; }