681 lines
28 KiB
TypeScript
681 lines
28 KiB
TypeScript
/**
|
||
* 首页布局纯函数模块(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 };
|
||
}
|