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

This commit is contained in:
chenxing
2026-09-10 15:09:10 +08:00
parent 7c925cd4f2
commit 34733ec280
16 changed files with 2399 additions and 840 deletions
+314
View File
@@ -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 };
}