[CMS-VISUAL] D1-D10: 组件库全量——布局模型扩 6 新类型(stats_band/featured_topics/video_tour/events/people/badges)+ 编辑器新属性面板(统计项/专题卡/徽章数组编辑器、图标选择器)+ gallery 瀑布流 + banner 4 Tab(topic/event 位)+ 人物管理 CRUD 页 + 全类型英文副题(176 测试绿)

This commit is contained in:
chenxing
2026-09-28 13:48:51 +08:00
parent 1a913490ef
commit 49678a6568
12 changed files with 1196 additions and 15 deletions
+268 -8
View File
@@ -9,13 +9,41 @@
*/
import { CategoryTreeNode } from "@/types/cms";
/** 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
export type BlockType = "article_list" | "ticker" | "gallery" | "links" | "quick_links" | "notice_bar";
/** 区块类型白名单(与后端 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) */
/** 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 对应) */
@@ -60,6 +88,20 @@ export interface LayoutBlock {
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 {
@@ -69,9 +111,10 @@ export interface HomeLayout {
export const HOME_LAYOUT_VERSION = 1;
/** 服务端同款约束(SiteService §4.6 + v2 手册 §2.8) */
/** 服务端同款约束(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;
@@ -81,6 +124,14 @@ 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+|\/.+)$/;
@@ -146,7 +197,62 @@ function toLinkItems(v: unknown): QuickLinkItem[] | undefined {
if (name === null || url === null) {
continue;
}
items.push({ name, url });
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;
}
@@ -174,6 +280,24 @@ function normalizeBlock(obj: Record<string, unknown>, type: BlockType): LayoutBl
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;
@@ -217,16 +341,25 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
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 齐备)
// 编辑期允许存在未填写的空行,提交前整行剔除(后端要求 name/url 齐备);icon 可选随行
const items = (block.items || [])
.map(i => ({ name: (i.name || "").trim(), url: (i.url || "").trim() }))
.filter(i => i.name || i.url);
.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;
}
@@ -242,6 +375,30 @@ export function cleanBlock(block: LayoutBlock): Record<string, unknown> {
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;
}
@@ -334,6 +491,109 @@ export function validateHomeLayout(layout: HomeLayout | null): string[] {
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;
}