feat(cms): docs/16 站点设置整页化+首页布局编辑器- SiteSetting.vue整页7分区(基本信息/皮肤与外观/首页布局/基础展示/页脚信息/SEO与域名/扩展参数)锚点导航sticky+IntersectionObserver+分区卡片三段式每分区独立保存(configJson单一事实源+submitSection统一提交);创建模式仅①②可编辑其余锁定,创建成功原地replace到site/setting/:id;SiteWizard三步向导退役删除(门户预览收编portalPreviewUrl);SiteManage瘦身表格+行内编辑跳整页(adminPathOf子站带站点段);ImageAssetField图片双通道(上传+路径粘贴折叠);SiteConfigForm支持groups/showExtras/siteId+asset字段接ImageAssetField+三旧键表单不再展示(ABSORBED_LEGACY_KEYS静默保留);homeLayout三件套(homeLayout.ts模型parse/serialize/validate/genFromCategories一键生成+blockTypes.ts注册表showIf条件字段+HomeLayoutEditor.vue序列编辑增删显隐上移下移);routes site/new+site/setting/:id+legacy redirect;CategoryManage home_style提示改口指向首页布局;新增siteSetting.spec+homeLayout.spec,重写siteManage.spec,117用例全绿
build / build (push) Successful in 2m19s
build / build (push) Successful in 2m19s
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 首页布局纯函数模块(docs/16 主线B §4.1/§4.5)。
|
||||
* 布局存 config_json.home_layout(零 DDL):{version:1, blocks:[…]};无该键 = 回落现状渲染(老站零迁移)。
|
||||
* 与后端契约对齐:type 白名单(article_list/ticker/gallery/links)、size 1~20、title/subtitle ≤100 字;
|
||||
* 服务端 SiteService.validateHomeLayout 强校验,本模块做编辑端同语义校验与往返装拆。
|
||||
* vitest 单测对象:homeLayout.spec.ts,不依赖组件挂载基建。
|
||||
*/
|
||||
import { CategoryTreeNode } from "@/types/cms";
|
||||
|
||||
/** v1 区块类型白名单(与后端 SiteService HOME_LAYOUT_BLOCK_TYPES 一致) */
|
||||
export type BlockType = "article_list" | "ticker" | "gallery" | "links";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export interface HomeLayout {
|
||||
version: number;
|
||||
blocks: LayoutBlock[];
|
||||
}
|
||||
|
||||
export const HOME_LAYOUT_VERSION = 1;
|
||||
|
||||
/** 服务端同款约束(SiteService §4.6) */
|
||||
export const BLOCK_TYPES_ALLOWED: BlockType[] = ["article_list", "ticker", "gallery", "links"];
|
||||
export const SIZE_MIN = 1;
|
||||
export const SIZE_MAX = 20;
|
||||
export const TITLE_MAX = 100;
|
||||
export const MAX_BLOCKS = 30;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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"];
|
||||
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;
|
||||
}
|
||||
} else if (block.type === "links") {
|
||||
const group = (block.group || "").trim();
|
||||
if (group) {
|
||||
out["group"] = group;
|
||||
}
|
||||
}
|
||||
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}:指定栏目模式请选择栏目`);
|
||||
}
|
||||
});
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user