[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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user