Merge branch 'agent/fs/lzm-92-decoration-dsl-contract' into 'main'

LZM-92: schema-drive decoration field contracts

See merge request ecshopx/ecshopx-admin!2
This commit is contained in:
陆凯杰
2026-07-03 09:12:14 +00:00
32 changed files with 650 additions and 52 deletions

View File

@@ -19,7 +19,9 @@
"commit:comment": "引导设置规范化的提交信息",
"commit": "git-cz",
"husky": "npx husky install",
"husky:add": "npx husky add .husky/pre-commit 'npx --no-install lint-staged' & npx husky add .husky/commit-msg 'npx --no-install commitlint --edit \"$1\"'"
"husky:add": "npx husky add .husky/pre-commit 'npx --no-install lint-staged' & npx husky add .husky/commit-msg 'npx --no-install commitlint --edit \"$1\"'",
"export:decoration-schema": "node scripts/export-decoration-schema.mjs ../ecshopx-web/app/decoration-engine/schema/decoration-schema.json",
"lint:decoration-contract": "node scripts/check-decoration-contract.mjs && npm run export:decoration-schema"
},
"dependencies": {
"@antv/g2plot": "^2.4.31",

View File

@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { copyFileSync, mkdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const ROOT = process.cwd()
const tempDir = path.join(tmpdir(), 'ecx-decoration-contract')
const tempSchemaPath = path.join(tempDir, `schema-${process.pid}.mjs`)
mkdirSync(tempDir, { recursive: true })
copyFileSync(
path.resolve(ROOT, 'src/components/sp-web-decoration/definitions/schema.js'),
tempSchemaPath
)
const schemaUrl = pathToFileURL(tempSchemaPath).href
const {
blockFields,
blockTypeAliases,
sectionFields,
sectionTypeAliases,
toDefaultSettings
} = await import(schemaUrl)
rmSync(tempSchemaPath, { force: true })
const EXPECTED_SECTIONS = [
'header',
'footer',
'announcement-bar',
'main-carousel',
'image-hotspot',
'product-shelf',
'product-tab-shelf',
'native-product-list'
]
const EXPECTED_BLOCKS = [
'announcement',
'header_product_list',
'header_collection_product_list',
'mega_menu',
'image',
'video',
'hotspot',
'product-tab',
'footer-link',
'footer-menu',
'footer-image',
'footer-text'
]
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object || {}, key)
}
function assertFields(groupName, schemas, expectedTypes) {
assert.deepEqual(
Object.keys(schemas).sort(),
expectedTypes.slice().sort(),
`${groupName} schema type list drifted`
)
expectedTypes.forEach((type) => {
const fields = schemas[type]
assert.ok(fields && typeof fields === 'object', `${groupName} ${type} missing fields`)
Object.entries(fields).forEach(([name, spec]) => {
assert.ok(spec && typeof spec === 'object', `${groupName} ${type}.${name} missing spec`)
assert.ok(typeof spec.type === 'string', `${groupName} ${type}.${name} missing type`)
assert.ok(hasOwn(spec, 'default'), `${groupName} ${type}.${name} missing default`)
if (spec.type === 'enum') {
assert.ok(Array.isArray(spec.values), `${groupName} ${type}.${name} enum missing values`)
assert.ok(
spec.values.includes(spec.default),
`${groupName} ${type}.${name} default is not in enum values`
)
}
;(spec.aliases || []).forEach((alias) => {
assert.notEqual(alias, name, `${groupName} ${type}.${name} has self alias`)
assert.ok(
!hasOwn(fields, alias),
`${groupName} ${type}.${name} alias ${alias} is also a canonical field`
)
})
})
})
}
function assertNoAliasDefaults(groupName, schemas) {
Object.entries(schemas).forEach(([type, fields]) => {
const defaults = toDefaultSettings(fields)
Object.entries(fields).forEach(([name, spec]) => {
assert.ok(hasOwn(defaults, name), `${groupName} ${type}.${name} missing default output`)
;(spec.aliases || []).forEach((alias) => {
assert.ok(
!hasOwn(defaults, alias),
`${groupName} ${type} defaultSettings contains alias ${alias}`
)
})
})
})
}
assertFields('section', sectionFields, EXPECTED_SECTIONS)
assertFields('block', blockFields, EXPECTED_BLOCKS)
assertNoAliasDefaults('section', sectionFields)
assertNoAliasDefaults('block', blockFields)
assert.deepEqual(sectionTypeAliases, { carousel: 'main-carousel' })
assert.deepEqual(blockTypeAliases, {})
assert.deepEqual(sectionFields['product-shelf'].displayMode.aliases, ['sourceMode'])
assert.deepEqual(blockFields.image.pc_image.aliases, ['imageUrl'])
assert.deepEqual(blockFields.image.mobile_image.aliases, ['imageUrl'])
assert.ok(blockFields['footer-menu'].menu)
assert.ok(blockFields['footer-menu'].menu_items)
assert.equal(blockFields['footer-menu'].menu.aliases, undefined)
assert.ok(!blockFields['footer-link'].url.aliases?.includes('link'))
console.log('Decoration contract checks passed')

View File

@@ -0,0 +1,26 @@
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
const root = process.cwd()
const tempDir = path.join(tmpdir(), 'ecx-decoration-contract')
const tempSchemaPath = path.join(tempDir, `schema-${process.pid}.mjs`)
mkdirSync(tempDir, { recursive: true })
copyFileSync(
path.resolve(root, 'src/components/sp-web-decoration/definitions/schema.js'),
tempSchemaPath
)
const schemaUrl = pathToFileURL(tempSchemaPath).href
const { createDecorationSchema } = await import(schemaUrl)
rmSync(tempSchemaPath, { force: true })
const outputPath = process.argv[2]
if (!outputPath) {
throw new Error('Usage: node scripts/export-decoration-schema.mjs <output-path>')
}
const resolvedOutput = path.resolve(root, outputPath)
mkdirSync(path.dirname(resolvedOutput), { recursive: true })
writeFileSync(resolvedOutput, `${JSON.stringify(createDecorationSchema(), null, 2)}\n`, 'utf8')
console.log(`Exported decoration schema to ${resolvedOutput}`)

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'announcement',
name: i18n.t('d65dbcac.fa86f1'),
defaultSettings: {
fields: blockFields.announcement,
defaultSettings: toDefaultSettings(blockFields.announcement),
legacyDefaultSettings: {
text: ''
}
}

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'footer-image',
name: '图片',
defaultSettings: {
fields: blockFields['footer-image'],
defaultSettings: toDefaultSettings(blockFields['footer-image']),
legacyDefaultSettings: {
image: '',
alignment: 'left',
width: '100',

View File

@@ -1,8 +1,11 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'footer-link',
name: i18n.t('d65dbcac.406baf'),
defaultSettings: {
fields: blockFields['footer-link'],
defaultSettings: toDefaultSettings(blockFields['footer-link']),
legacyDefaultSettings: {
label: '',
url: '/help'
}

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'footer-menu',
name: '菜单',
defaultSettings: {
fields: blockFields['footer-menu'],
defaultSettings: toDefaultSettings(blockFields['footer-menu']),
legacyDefaultSettings: {
title: '',
menu: null,
size: 'small',

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'footer-text',
name: '文本',
defaultSettings: {
fields: blockFields['footer-text'],
defaultSettings: toDefaultSettings(blockFields['footer-text']),
legacyDefaultSettings: {
title: '',
content: '',
title_size: 'medium',

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'header_collection_product_list',
name: '产品系列菜单',
defaultSettings: {
fields: blockFields.header_collection_product_list,
defaultSettings: toDefaultSettings(blockFields.header_collection_product_list),
legacyDefaultSettings: {
title: '',
link: '',
columns_desktop: 4,

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'header_product_list',
name: '产品菜单',
defaultSettings: {
fields: blockFields.header_product_list,
defaultSettings: toDefaultSettings(blockFields.header_product_list),
legacyDefaultSettings: {
title: '',
link: '',
columns_desktop: 4,

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'hotspot',
name: i18n.t('d65dbcac.50da72'),
defaultSettings: {
fields: blockFields.hotspot,
defaultSettings: toDefaultSettings(blockFields.hotspot),
legacyDefaultSettings: {
label: '',
x: 0,
y: 0,

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'image',
name: '图片',
defaultSettings: {
fields: blockFields.image,
defaultSettings: toDefaultSettings(blockFields.image),
legacyDefaultSettings: {
pc_image: '',
mobile_image: '',
introduction: '提供背景或介绍,吸引用户注意',

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'mega_menu',
name: '超级菜单',
defaultSettings: {
fields: blockFields.mega_menu,
defaultSettings: toDefaultSettings(blockFields.mega_menu),
legacyDefaultSettings: {
mega_menu_item: '',
alignment: 'page',
image_position: 'right',

View File

@@ -1,7 +1,11 @@
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'product-tab',
name: '产品系列',
defaultSettings: {
fields: blockFields['product-tab'],
defaultSettings: toDefaultSettings(blockFields['product-tab']),
legacyDefaultSettings: {
tab_label: '产品系列',
product_ids: [],
limit: 8

View File

@@ -1,9 +1,12 @@
import { i18n } from '@/i18n'
import { blockFields, toDefaultSettings } from '../schema.js'
export default {
type: 'video',
name: i18n.t('bdd52c70.7fcf42'),
defaultSettings: {
fields: blockFields.video,
defaultSettings: toDefaultSettings(blockFields.video),
legacyDefaultSettings: {
videoUrl: '',
posterUrl: '',
mobileVideoUrl: '',

View File

@@ -19,6 +19,14 @@ import footerLinkBlock from './blocks/footer-link.js'
import footerMenuBlock from './blocks/footer-menu.js'
import footerImageBlock from './blocks/footer-image.js'
import footerTextBlock from './blocks/footer-text.js'
import {
blockTypeAliases,
getBlockFields,
getSectionFields,
resolveBlockType,
resolveSectionType,
sectionTypeAliases
} from './schema.js'
const sectionDefinitions = {
[headerSection.type]: headerSection,
@@ -53,11 +61,11 @@ function getSectionTypesByScope(scope) {
}
export function getTypedSectionDefinition(type) {
return sectionDefinitions[type] || null
return sectionDefinitions[resolveSectionType(type)] || null
}
export function getTypedBlockDefinition(type) {
return blockDefinitions[type] || null
return blockDefinitions[resolveBlockType(type)] || null
}
export function getSectionDefaultSettings(type) {
@@ -68,6 +76,14 @@ export function getBlockDefaultSettings(type) {
return getTypedBlockDefinition(type)?.defaultSettings || null
}
export function getSectionSchemaFields(type) {
return getSectionFields(type)
}
export function getBlockSchemaFields(type) {
return getBlockFields(type)
}
export function getAllowedBlockTypes(sectionType) {
return getTypedSectionDefinition(sectionType)?.blocks?.allowed?.slice() || []
}
@@ -80,7 +96,15 @@ export function getSectionBlockTypes(sectionType) {
return getAllowedBlockTypes(sectionType)
}
export { sectionDefinitions, blockDefinitions, getSectionTypesByScope }
export {
sectionDefinitions,
blockDefinitions,
sectionTypeAliases,
blockTypeAliases,
getSectionTypesByScope,
resolveSectionType,
resolveBlockType
}
export default {
sectionDefinitions,
@@ -89,8 +113,12 @@ export default {
getTypedBlockDefinition,
getSectionDefaultSettings,
getBlockDefaultSettings,
getSectionSchemaFields,
getBlockSchemaFields,
getAllowedBlockTypes,
getSectionTypes,
getSectionBlockTypes,
getSectionTypesByScope
getSectionTypesByScope,
resolveSectionType,
resolveBlockType
}

View File

@@ -0,0 +1,296 @@
function field(type, defaultValue, extra = {}) {
return {
type,
default: defaultValue,
...extra
}
}
export const sectionTypeAliases = {
carousel: 'main-carousel'
}
export const blockTypeAliases = {}
export const sectionFields = {
header: {
color_mode: field('enum', 'dark', { values: ['light', 'dark'] }),
full_width: field('boolean', false),
sticky_header_type: field('string', 'none'),
padding_top: field('string', 'xxs'),
menu_color_style: field('string', 'pure'),
menu_type_desktop: field('string', 'dropdown'),
show_line_separator: field('boolean', true),
menu: field('object', null),
color_scheme: field('string', 'scheme-1'),
layout: field('string', 'middle'),
margin_bottom: field('number', 0),
enable_country_selector: field('boolean', true),
mobile_logo_position: field('string', 'center'),
menu_color_mode: field('enum', 'dark', { values: ['light', 'dark'] }),
logo_position: field('string', 'center'),
enable_customer_avatar: field('boolean', true),
padding_bottom: field('string', 'xxs'),
menu_color_scheme: field('string', 'scheme-1'),
enable_language_selector: field('boolean', true)
},
footer: {
content_alignment: field('string', 'center'),
title: field('string', ''),
copyright: field('string', ''),
newsletter_heading: field('string', ''),
color_mode: field('enum', 'dark', { values: ['light', 'dark'] }),
margin_top: field('number', 48),
full_width: field('boolean', false),
padding_top: field('string', 'm'),
color_scheme: field('string', 'scheme-3'),
show_social: field('boolean', true),
payment_enable: field('boolean', true),
newsletter_enable: field('boolean', false),
enable_country_selector: field('boolean', true),
show_policy: field('boolean', true),
padding_bottom: field('string', 'xs'),
enable_brand_information: field('boolean', true),
enable_language_selector: field('boolean', true)
},
'announcement-bar': {
full_width: field('boolean', false),
auto_rotate: field('boolean', false),
enable_country_selector: field('boolean', false),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
show_line_separator: field('boolean', true),
change_slides_speed: field('number', 5),
color_scheme: field('string', 'scheme-1'),
padding_top: field('string', 'none'),
padding_bottom: field('string', 'none'),
show_social: field('boolean', false),
enable_language_selector: field('boolean', false)
},
'main-carousel': {
image_height: field('enum', 'medium', { values: ['small', 'medium', 'large', 'adapt'] }),
height: field('string', ''),
paginate_type: field('enum', 'point', { values: ['point', 'counter', 'number'] }),
paginate_size: field('enum', 'medium', { values: ['small', 'medium', 'large'] }),
enable_auto_play: field('boolean', false),
enable_arrow: field('boolean', true),
showDots: field('boolean', true),
full_width: field('boolean', false),
interval: field('number', 5),
show_text_below: field('boolean', false),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_scheme: field('string', 'scheme-1'),
padding_top: field('string', 'm'),
padding_bottom: field('string', 'm')
},
'image-hotspot': {
pc_image: field('string', '', { aliases: ['imageUrl', 'image_url', 'image'] }),
mobile_image: field('string', '', { aliases: ['mobileImage', 'mobile_image_url'] }),
pc_hotspots: field('array', []),
mobile_hotspots: field('array', []),
full_width: field('boolean', false),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_scheme: field('string', 'scheme-1'),
padding_top: field('string', 'm'),
padding_bottom: field('string', 'm')
},
'product-shelf': {
title: field('string', ''),
displayMode: field('enum', 'manual', {
values: ['category', 'manual'],
aliases: ['sourceMode']
}),
itemIds: field('string[]', []),
categoryId: field('string', ''),
columns: field('number', 4),
limit: field('number', 8),
showPrice: field('boolean', true),
showAddCart: field('boolean', true),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_scheme: field('string', 'scheme-1'),
padding_top: field('string', 'm'),
padding_bottom: field('string', 'm'),
full_width: field('boolean', false)
},
'product-tab-shelf': {
title: field('string', ''),
intro: field('string', '新品'),
size: field('enum', 'medium', {
values: ['xsmall', 'small', 'medium', 'large', 'xlarge']
}),
alignment: field('enum', 'center', { values: ['left', 'center', 'right'] }),
columns: field('number', 4),
spacing: field('enum', 'medium', { values: ['none', 'small', 'medium', 'large'] }),
full_width: field('boolean', false),
show_price: field('boolean', true),
show_add_cart: field('boolean', true),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_scheme: field('string', 'scheme-1'),
padding_top: field('string', 'm'),
padding_bottom: field('string', 'm')
},
'native-product-list': {
locked: field('boolean', true)
}
}
export const blockFields = {
announcement: {
text: field('string', '')
},
header_product_list: {
title: field('string', ''),
link: field('string', ''),
columns_desktop: field('number', 4),
full_width: field('boolean', false),
show_discount: field('boolean', false),
image_ratio: field('string', 'square'),
show_secondary_image: field('boolean', false),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_style: field('string', 'pure')
},
header_collection_product_list: {
title: field('string', ''),
link: field('string', ''),
columns_desktop: field('number', 4),
full_width: field('boolean', false),
show_discount: field('boolean', false),
image_ratio: field('string', 'adapt'),
show_secondary_image: field('boolean', false),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_style: field('string', 'pure')
},
mega_menu: {
mega_menu_item: field('string', ''),
alignment: field('string', 'page'),
image_position: field('string', 'right'),
image_length: field('string', 'one'),
enable_image_1_link: field('boolean', false),
enable_image_2_link: field('boolean', false),
enable_image_3_link: field('boolean', false),
image_layout: field('string', 'outside'),
image_content_alignment: field('string', 'center'),
image_ratio: field('number', 1),
image_shape: field('string', 'none'),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
color_style: field('string', 'pure')
},
image: {
pc_image: field('string', '', { aliases: ['imageUrl'] }),
mobile_image: field('string', '', { aliases: ['imageUrl'] }),
introduction: field('string', '提供背景或介绍,吸引用户注意'),
introduction_size: field('string', 's'),
heading: field('string', '关键主题或焦点,简明直接'),
heading_size: field('string', 'm'),
description: field('string', '简要概述幻灯片内容,突出重点'),
description_size: field('string', 's'),
button_text: field('string', '按钮文字'),
button_size: field('string', 'large'),
button_style: field('string', 'primary'),
button_scheme: field('string', 'brand'),
content_alignment: field('string', 'left'),
content_layout: field('string', 'top-left'),
image_opacity: field('number', 20),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
enable_card: field('boolean', false),
color_style: field('string', 'pure')
},
video: {
videoUrl: field('string', ''),
posterUrl: field('string', ''),
mobileVideoUrl: field('string', ''),
introduction: field('string', '<div>1231231</div>'),
introduction_size: field('string', 'l'),
heading: field('string', '关键主题或焦点,简明直接'),
heading_size: field('string', 'm'),
description: field('string', '简要概述幻灯片内容,突出重点'),
description_size: field('string', 'm'),
button_text: field('string', '按钮文字'),
button_size: field('string', 'small'),
button_style: field('string', 'primary'),
button_scheme: field('string', 'brand'),
content_alignment: field('string', 'left'),
content_layout: field('string', 'middle-left'),
image_opacity: field('number', 15),
color_mode: field('enum', 'light', { values: ['light', 'dark'] }),
enable_card: field('boolean', true),
color_style: field('string', 'pure')
},
hotspot: {
label: field('string', ''),
x: field('number', 0),
y: field('number', 0),
link: field('string', ''),
shape: field('enum', 'circle', { values: ['circle', 'rect'] })
},
'product-tab': {
tab_label: field('string', '产品系列'),
product_ids: field('string[]', []),
product_snapshots: field('array', []),
limit: field('number', 8)
},
'footer-link': {
label: field('string', ''),
url: field('string', '/help')
},
'footer-menu': {
title: field('string', ''),
menu: field('object', null),
menu_items: field('array', []),
size: field('string', 'small'),
alignment: field('string', 'left'),
column_span: field('number', 3)
},
'footer-image': {
image: field('string', ''),
alignment: field('string', 'left'),
width: field('string', '100'),
radius: field('string', 'medium'),
column_span: field('number', 4)
},
'footer-text': {
title: field('string', ''),
content: field('string', ''),
title_size: field('string', 'medium'),
content_size: field('string', 'small'),
alignment: field('string', 'left'),
column_span: field('number', 6)
}
}
export function cloneValue(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value))
}
export function toDefaultSettings(fields = {}) {
return Object.entries(fields).reduce((settings, [name, spec]) => {
settings[name] = cloneValue(spec.default)
return settings
}, {})
}
export function resolveSectionType(type) {
return sectionTypeAliases[type] || type
}
export function resolveBlockType(type) {
return blockTypeAliases[type] || type
}
export function getSectionFields(type) {
return sectionFields[resolveSectionType(type)] || null
}
export function getBlockFields(type) {
return blockFields[resolveBlockType(type)] || null
}
export function createDecorationSchema() {
return {
sections: cloneValue(sectionFields),
blocks: cloneValue(blockFields),
aliases: {
sections: cloneValue(sectionTypeAliases),
blocks: cloneValue(blockTypeAliases)
}
}
}

View File

@@ -1,10 +1,13 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'announcement-bar',
scope: 'header',
name: i18n.t('d65dbcac.3921b6'),
defaultSettings: {
fields: sectionFields['announcement-bar'],
defaultSettings: toDefaultSettings(sectionFields['announcement-bar']),
legacyDefaultSettings: {
full_width: false,
auto_rotate: false,
enable_country_selector: false,

View File

@@ -1,10 +1,13 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'main-carousel',
scope: 'template',
name: i18n.t('d65dbcac.0c0180'),
defaultSettings: {
fields: sectionFields['main-carousel'],
defaultSettings: toDefaultSettings(sectionFields['main-carousel']),
legacyDefaultSettings: {
image_height: 'medium',
paginate_type: 'point',
paginate_size: 'medium',

View File

@@ -1,11 +1,14 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'footer',
scope: 'footer',
fixedId: 'footer',
name: i18n.t('d65dbcac.4eb88f'),
defaultSettings: {
fields: sectionFields.footer,
defaultSettings: toDefaultSettings(sectionFields.footer),
legacyDefaultSettings: {
content_alignment: 'center',
title: '',
copyright: '',

View File

@@ -1,11 +1,14 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'header',
scope: 'header',
fixedId: 'header',
name: i18n.t('d65dbcac.917f14'),
defaultSettings: {
fields: sectionFields.header,
defaultSettings: toDefaultSettings(sectionFields.header),
legacyDefaultSettings: {
color_mode: 'dark',
full_width: false,
sticky_header_type: 'none',

View File

@@ -1,10 +1,13 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'image-hotspot',
scope: 'template',
name: i18n.t('d65dbcac.081a81'),
defaultSettings: {
fields: sectionFields['image-hotspot'],
defaultSettings: toDefaultSettings(sectionFields['image-hotspot']),
legacyDefaultSettings: {
pc_image: '',
mobile_image: '',
pc_hotspots: [],

View File

@@ -1,3 +1,5 @@
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'native-product-list',
scope: 'template',
@@ -6,7 +8,9 @@ export default {
native: true,
locked: true,
addable: false,
defaultSettings: {
fields: sectionFields['native-product-list'],
defaultSettings: toDefaultSettings(sectionFields['native-product-list']),
legacyDefaultSettings: {
locked: true
},
defaultBlocks: [],

View File

@@ -1,10 +1,13 @@
import { i18n } from '@/i18n'
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'product-shelf',
scope: 'template',
name: i18n.t('d65dbcac.4a02ad'),
defaultSettings: {
fields: sectionFields['product-shelf'],
defaultSettings: toDefaultSettings(sectionFields['product-shelf']),
legacyDefaultSettings: {
title: '',
sourceMode: 'manual',
itemIds: [],

View File

@@ -1,8 +1,12 @@
import { sectionFields, toDefaultSettings } from '../schema.js'
export default {
type: 'product-tab-shelf',
scope: 'template',
name: 'Tab产品系列',
defaultSettings: {
fields: sectionFields['product-tab-shelf'],
defaultSettings: toDefaultSettings(sectionFields['product-tab-shelf']),
legacyDefaultSettings: {
title: '',
intro: '新品',
size: 'medium',

View File

@@ -3,14 +3,14 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">图片</div>
<div class="rounded-xl border border-border bg-card p-3">
<SpImagePicker :value="value.image" @input="updateField('image', $event)" />
<SpImagePicker :value="settings.image" @input="updateField('image', $event)" />
</div>
</section>
<section class="space-y-2">
<div class="text-sm text-muted-foreground">对齐方式</div>
<el-radio-group
:value="value.alignment || 'left'"
:value="settings.alignment"
size="small"
@input="updateField('alignment', $event)"
>
@@ -23,7 +23,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">图片宽度</div>
<el-radio-group
:value="value.width || '100'"
:value="settings.width"
size="small"
@input="updateField('width', $event)"
>
@@ -37,7 +37,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">圆角</div>
<el-radio-group
:value="value.radius || 'medium'"
:value="settings.radius"
size="small"
@input="updateField('radius', $event)"
>
@@ -53,7 +53,7 @@
<div class="text-sm text-muted-foreground">占列宽度</div>
<div class="flex items-center gap-3">
<el-input-number
:value="value.column_span || 4"
:value="settings.column_span"
:min="1"
:max="12"
size="small"
@@ -62,7 +62,7 @@
@change="updateField('column_span', $event)"
/>
<el-slider
:value="value.column_span || 4"
:value="settings.column_span"
:min="1"
:max="12"
:step="1"
@@ -77,6 +77,7 @@
<script>
import SpImagePicker from '@/components/sp-image-picker/index.vue'
import { normalizeTypedBlockSettings } from '../utils/panelState.js'
export default {
name: 'FooterImageBlockPanel',
@@ -87,6 +88,11 @@ export default {
default: () => ({})
}
},
computed: {
settings() {
return normalizeTypedBlockSettings('footer-image', this.value)
}
},
methods: {
updateField(field, fieldValue) {
this.$emit('change', {

View File

@@ -1,16 +1,18 @@
<template>
<el-form label-position="top" size="small" class="space-y-3">
<el-form-item :label="$t('5702d2d6.14d342')">
<el-input :value="value.label" @input="updateField('label', $event)" />
<el-input :value="settings.label" @input="updateField('label', $event)" />
</el-form-item>
<el-form-item :label="$t('5702d2d6.dec2eb')">
<el-input :value="value.url" @input="updateField('url', $event)" />
<el-input :value="settings.url" @input="updateField('url', $event)" />
</el-form-item>
</el-form>
</template>
<script>
import { normalizeTypedBlockSettings } from '../utils/panelState.js'
export default {
name: 'FooterLinkBlockPanel',
props: {
@@ -19,6 +21,11 @@ export default {
default: () => ({})
}
},
computed: {
settings() {
return normalizeTypedBlockSettings('footer-link', this.value)
}
},
methods: {
updateField(field, fieldValue) {
this.$emit('change', {

View File

@@ -3,7 +3,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">标题</div>
<SpRichTextEditor
:value="value.title"
:value="settings.title"
placeholder="请输入标题"
@input="updateField('title', $event)"
/>
@@ -46,7 +46,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">规格</div>
<el-select
:value="value.size || 'small'"
:value="settings.size"
size="small"
class="w-full"
@change="updateField('size', $event)"
@@ -62,7 +62,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">对齐方式</div>
<el-radio-group
:value="value.alignment || 'left'"
:value="settings.alignment"
size="small"
@input="updateField('alignment', $event)"
>
@@ -76,7 +76,7 @@
<div class="text-sm text-muted-foreground">占列宽度</div>
<div class="flex items-center gap-3">
<el-input-number
:value="value.column_span || 3"
:value="settings.column_span"
:min="1"
:max="12"
size="small"
@@ -85,7 +85,7 @@
@change="updateField('column_span', $event)"
/>
<el-slider
:value="value.column_span || 3"
:value="settings.column_span"
:min="1"
:max="12"
:step="1"
@@ -103,6 +103,7 @@ import { Menu as MenuIcon, Trash2 } from 'lucide-vue'
import WebNavPicker from '@/components/sp-picker-plus/WebNavPicker.vue'
import SpRichTextEditor from '@/components/sp-rich-text-editor/index.vue'
import { normalizeSelectedMenu, pickMenuValue } from '../utils/menuSelection.js'
import { normalizeTypedBlockSettings } from '../utils/panelState.js'
export default {
name: 'FooterMenuBlockPanel',
@@ -123,8 +124,11 @@ export default {
}
},
computed: {
settings() {
return normalizeTypedBlockSettings('footer-menu', this.value)
},
currentMenu() {
return normalizeSelectedMenu(pickMenuValue(this.value))
return normalizeSelectedMenu(pickMenuValue(this.settings))
},
currentMenuLabel() {
return this.currentMenu?.name || this.menuNameCache || '未选择菜单'

View File

@@ -2,25 +2,29 @@
<div class="space-y-5 text-foreground">
<section class="space-y-2">
<div class="text-sm text-muted-foreground">{{ $t('6c727f2c.5a02fa') }}</div>
<el-input :value="value.title" size="small" @input="updateField('title', $event)" />
<el-input :value="settings.title" size="small" @input="updateField('title', $event)" />
</section>
<section class="space-y-2">
<div class="text-sm text-muted-foreground">{{ $t('6c727f2c.f0c7e9') }}</div>
<el-select
:value="value.sourceMode"
:value="settings.displayMode"
size="small"
class="w-full"
@input="updateField('sourceMode', $event)"
@input="updateField('displayMode', $event)"
>
<el-option :label="$t('6c727f2c.68924b')" value="manual" />
<el-option :label="$t('6c727f2c.d282eb')" value="category" />
</el-select>
</section>
<section v-if="value.sourceMode === 'category'" class="space-y-2">
<section v-if="settings.displayMode === 'category'" class="space-y-2">
<div class="text-sm text-muted-foreground">{{ $t('6c727f2c.ae8c70') }}</div>
<el-input :value="value.categoryId" size="small" @input="updateField('categoryId', $event)" />
<el-input
:value="settings.categoryId"
size="small"
@input="updateField('categoryId', $event)"
/>
</section>
<section v-else class="space-y-2">
@@ -38,7 +42,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">{{ $t('6c727f2c.eb22d4') }}</div>
<el-input-number
:value="value.columns"
:value="settings.columns"
:min="2"
:max="6"
size="small"
@@ -51,7 +55,7 @@
<section class="space-y-2">
<div class="text-sm text-muted-foreground">{{ $t('6c727f2c.d57936') }}</div>
<el-input-number
:value="value.limit"
:value="settings.limit"
:min="1"
:max="50"
size="small"
@@ -62,12 +66,13 @@
</section>
</div>
<SectionAppearancePanel :value="value" @change="updateField" />
<SectionAppearancePanel :value="settings" @change="updateField" />
</div>
</template>
<script>
import SectionAppearancePanel from './SectionAppearancePanel.vue'
import { normalizeTypedSectionSettings } from '../utils/panelState.js'
export default {
name: 'ProductShelfPanel',
@@ -79,8 +84,11 @@ export default {
}
},
computed: {
settings() {
return normalizeTypedSectionSettings('product-shelf', this.value)
},
itemIdsText() {
return Array.isArray(this.value.itemIds) ? this.value.itemIds.join(',') : ''
return Array.isArray(this.settings.itemIds) ? this.settings.itemIds.join(',') : ''
}
},
methods: {

View File

@@ -6,6 +6,7 @@ import { createFooterDocumentDsl } from '../definitions/documents/footer.js'
import { createGlobalSections } from '../definitions/documents/global.js'
import { createTypedSection } from '../definitions/factory.js'
import { generateBlockId } from './nanoid.js'
import { normalizeTypedBlockSettings, normalizeTypedSectionSettings } from './panelState.js'
function cloneValue(value) {
return JSON.parse(JSON.stringify(value))
@@ -367,8 +368,12 @@ export function serializeDsl(dsl) {
const nextDsl = cloneValue(dsl)
Object.keys(nextDsl.sections || {}).forEach((sectionId) => {
const section = nextDsl.sections[sectionId]
section.settings = normalizeTypedSectionSettings(section.type, section.settings)
Object.keys(section.blocks || {}).forEach((blockId) => {
const block = section.blocks[blockId]
if (block?.type) {
block.settings = normalizeTypedBlockSettings(block.type, block.settings)
}
if (block?.type === 'product-tab' && block.settings) {
delete block.settings.size
delete block.settings.size_override

View File

@@ -1,8 +1,11 @@
import {
getTypedBlockDefinition,
getBlockDefaultSettings,
getBlockSchemaFields,
getSectionDefaultSettings,
getBlockDefaultSettings
getSectionSchemaFields
} from '../definitions/registry.js'
import { normalizeBySchema } from './schemaNormalize.js'
function cloneValue(value) {
return JSON.parse(JSON.stringify(value))
@@ -26,7 +29,6 @@ function clampCoordinate(value) {
function normalizeProductShelfCompatibleSettings(settings) {
const nextSettings = { ...settings }
nextSettings.displayMode = nextSettings.displayMode || nextSettings.sourceMode || 'category'
nextSettings.itemIds = Array.isArray(nextSettings.itemIds)
? nextSettings.itemIds.filter(Boolean).slice(0, nextSettings.limit || 8)
: []
@@ -73,7 +75,10 @@ function normalizeHotspotCompatibleSettings(settings) {
}
function normalizeSectionSettings(type, settings = {}) {
const nextSettings = mergeWithDefaults(getSectionDefaultSettings(type), settings)
const fields = getSectionSchemaFields(type)
const nextSettings = fields
? normalizeBySchema(fields, settings)
: mergeWithDefaults(getSectionDefaultSettings(type), settings)
if (type === 'product-shelf') return normalizeProductShelfCompatibleSettings(nextSettings)
if (type === 'product-tab-shelf') return normalizeProductTabShelfSettings(nextSettings)
@@ -81,7 +86,10 @@ function normalizeSectionSettings(type, settings = {}) {
}
function normalizeBlockSettingsValue(type, settings = {}) {
const nextSettings = mergeWithDefaults(getBlockDefaultSettings(type), settings)
const fields = getBlockSchemaFields(type)
const nextSettings = fields
? normalizeBySchema(fields, settings)
: mergeWithDefaults(getBlockDefaultSettings(type), settings)
if (type === 'hotspot') return normalizeHotspotCompatibleSettings(nextSettings)
if (type === 'product-tab') return normalizeProductTabBlockSettings(nextSettings)

View File

@@ -0,0 +1,27 @@
import { cloneValue } from '../definitions/schema.js'
export function normalizeBySchema(fields, settings = {}) {
if (!fields) return cloneValue(settings || {})
const source = settings && typeof settings === 'object' ? settings : {}
const out = {}
Object.entries(fields).forEach(([name, spec]) => {
let value = source[name]
if (value === undefined && Array.isArray(spec.aliases)) {
for (const alias of spec.aliases) {
if (source[alias] !== undefined) {
value = source[alias]
break
}
}
}
out[name] = value === undefined ? cloneValue(spec.default) : cloneValue(value)
})
return out
}
export default {
normalizeBySchema
}