upgrade: adopt V8.34.1 (multi-image banner carousel + spec-table source modes)

Plain overlay (33 items, same-source lineage, zero patch replay).
Theme suites 35/35; full run 453 passed + 10 known Windows env fails
(suspect-module rerun confirms identical failure set).
Live: container 8.34.1-dev.1; hesupipe home leftover industrial-preset
blocks cleared via studio draft+publish flow — home now renders pure
trade-pro template (sf-section/Chinese blocks = 0).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-26 18:03:07 +08:00
parent 391d22f1a8
commit df2a183dbf
38 changed files with 1652 additions and 521 deletions
+68 -8
View File
@@ -9,16 +9,16 @@ from urllib.parse import urlparse
from .core import sanitize, route_path
BLOCKS = {
'hero': {'name':'封面横幅','icon':'✦','fields':['title','subtitle','description','image','button','href','align'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left'}},
'hero': {'name':'封面横幅','icon':'✦','fields':['title','subtitle','description','image','button','href','align','layout_variant','image_fit','slides'], 'defaults':{'title':'让品牌从这里出发','subtitle':'GLOBAL EXPERIENCE','description':'在这里呈现企业最重要的价值。','button':'探索产品','href':'/product/','align':'left','layout_variant':'split','image_fit':'cover','slides':[]}},
'richtext':{'name':'富文本','icon':'≡','fields':['title','body'],'defaults':{'title':'关于我们','body':'<p>在这里输入图文内容。</p>'}},
'features':{'name':'核心优势','icon':'▦','fields':['title','items'],'defaults':{'title':'我们的优势','items':[{'title':'专业服务','text':'从咨询到交付'},{'title':'品质保障','text':'严格的质量流程'},{'title':'全球网络','text':'连接不同市场'}]}},
'stats':{'name':'数字指标','icon':'#','fields':['title','items'],'defaults':{'title':'我们的实力','items':[{'value':'50,000+','label':'服务案例'},{'value':'70+','label':'市场覆盖'},{'value':'30+','label':'合作伙伴'}]}},
'collection':{'name':'动态内容列表','icon':'▣','fields':['title','subtitle','source','limit','columns','button','href'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/'}},
'collection':{'name':'动态内容列表','icon':'▣','fields':['title','subtitle','source','limit','columns','button','href','card_style','image_fit'],'defaults':{'title':'精选产品','subtitle':'为客户提供适合的解决方案','source':'product','limit':6,'columns':3,'button':'查看全部','href':'/product/','card_style':'standard','image_fit':'contain'}},
'image_text':{'name':'图文组合','icon':'◧','fields':['title','subtitle','description','image','image_side','button','href'],'defaults':{'title':'从采购到交付','subtitle':'OUR APPROACH','description':'展示业务流程和服务理念。','image':'','image_side':'right','button':'了解更多','href':'/about/'}},
'gallery':{'name':'图片画廊','icon':'▧','fields':['title','images','columns'],'defaults':{'title':'精彩瞬间','images':[],'columns':3}},
'faq':{'name':'常见问题','icon':'?','fields':['title','items'],'defaults':{'title':'常见问题','items':[{'question':'如何开始合作?','answer':'您可以通过联系我们页面提交需求。'}]}},
'cta':{'name':'行动号召','icon':'↗','fields':['title','subtitle','button','href'],'defaults':{'title':'开启新的合作','subtitle':'告诉我们您的需求,我们将提供适合的方案。','button':'联系我们','href':'/contact/'}},
'attributes':{'name':'参数信息表','icon':'▤','fields':['title'],'defaults':{'title':'产品参数'}},
'attributes':{'name':'参数信息表','icon':'▤','fields':['title','source_mode','items'],'defaults':{'title':'产品参数','source_mode':'page','items':[]}},
'layout':{'name':'多列自由布局','icon':'▥','fields':['columns','gap','tablet_columns','mobile_columns'], 'defaults':{'columns':[{'width':1,'blocks':[]},{'width':1,'blocks':[]}],'gap':24,'tablet_columns':2,'mobile_columns':1}},
'spacer':{'name':'上下留白','icon':'↕','fields':['height'],'defaults':{'height':48}},
'carousel':{'name':'图片轮播','icon':'▧','fields':['title','items'],'defaults':{'title':'精选展示','items':[{'image':'','alt':'展示图片','caption':'图片说明'}]}},
@@ -32,7 +32,7 @@ BLOCKS = {
# are not supported; all values are still verified by validate_blocks.
BLOCK_PROPERTY_SCHEMA={key:{
'type':'object','additionalProperties':False,
'properties':{field:{'type':('array' if field in ('items','images','columns') else
'properties':{field:{'type':('array' if field in ('items','images','columns','slides') else
'integer' if field in ('height','limit','gap','tablet_columns','mobile_columns') else
'string')} for field in spec['fields']},
'supported_devices':['desktop','tablet','mobile'],
@@ -104,6 +104,35 @@ def validate_blocks(blocks):
if not re.fullmatch('[a-zA-Z0-9_-]{4,50}',ident) or ident in found:raise ValueError('区块 ID 缺失或重复')
found.add(ident)
spec=BLOCKS[block['type']]
visual_options={
'hero':{'layout_variant':('split','image-first','centered'),'image_fit':('cover','contain')},
'collection':{'card_style':('standard','compact','showcase'),'image_fit':('cover','contain')},
}
for visual_key, choices in visual_options.get(block['type'],{}).items():
if visual_key in block and (not isinstance(block[visual_key],str) or block[visual_key] not in choices):
raise ValueError('不支持的区块展示选项: '+visual_key)
# V8.34.1: strict item schemas. Never trust arbitrary HTML/URLs in slides.
if block['type']=='hero' and 'slides' in block:
slides=block['slides']
if not isinstance(slides,list) or len(slides)>6:
raise ValueError('首页横幅最多支持6张')
fields=('title','subtitle','description','image','button','href')
for slide in slides:
if not isinstance(slide,dict) or set(slide)-set(fields):
raise ValueError('横幅内容字段无效')
if any(not isinstance(v,str) or len(v)>2000 for v in slide.values()):
raise ValueError('横幅文字或图片地址无效')
if block['type']=='attributes':
mode=block.get('source_mode','page')
if not isinstance(mode,str) or mode not in ('page','custom','merge'):
raise ValueError('参数来源方式无效')
items=block.get('items',[])
if not isinstance(items,list) or len(items)>30:
raise ValueError('参数最多支持30项')
for item in items:
if (not isinstance(item,dict) or set(item)!={'label','value'} or
any(not isinstance(item[k],str) or len(item[k])>300 for k in ('label','value'))):
raise ValueError('参数项必须包含名称和数值')
allowed=set(spec['fields'])|{'id','type','visible','style','shared_ref'}
normalized={key:val for key,val in block.items() if key in allowed}
normalized['id']=ident
@@ -153,6 +182,7 @@ def validate_blocks(blocks):
normalized['gap']=max(0,min(64,int(block.get('gap',24))))
normalized['tablet_columns']=tablet_cols
normalized['mobile_columns']=mobile_cols
if block['type']=='hero' and 'slides' in block: normalized['slides']=[dict(x) for x in block['slides']]
if block['type'] in ('carousel','tabs','pricing'):
items=block.get('items',[])
if not isinstance(items,list) or len(items)>16:raise ValueError('轮播、标签和方案最多16条')
@@ -245,9 +275,30 @@ def render_blocks(blocks, site, page, entries):
start=f'<section class="sf-section sf-{typ}" data-block="{escape(block["id"])}"{style}><div class="sf-container">'
end='</div></section>'
if typ=='hero':
slides=block.get('slides') or []
if slides:
panels=[]
for idx,slide in enumerate(slides[:6]):
# Text binding is resolved per slide; outputs are escaped at HTML boundaries.
st=escape(resolve_text(slide.get('title',''),site,page))
ss=escape(resolve_text(slide.get('subtitle',''),site,page))
sd=escape(resolve_text(slide.get('description',''),site,page))
si=safe_media(resolve_text(slide.get('image',''),site,page))
button=escape(resolve_text(slide.get('button',''),site,page) or '查看详情')
link=safe_href(resolve_text(slide.get('href','/product/'),site,page))
art=(f'<div class="sf-hero-img"><img src="{si}" alt="{st}" loading="{'eager' if idx==0 and page.get('route')=='/' else 'lazy'}"{' fetchpriority="high"' if idx==0 and page.get('route')=='/' else ''}></div>' if si else '<div class="sf-hero-img sf-hero-abstract" aria-hidden="true"><span>✧</span></div>')
heading='h1' if idx==0 else 'h2' # one primary page heading across all slides
panels.append(f'<div class="sf-hero-panel" role="group" aria-label="横幅 {idx+1} / {len(slides)}"><div class="sf-hero-grid sf-hero-layout-{block.get("layout_variant","split")} sf-fit-{block.get("image_fit","cover")}"><div class="sf-hero-copy"><span class="sf-kicker">{ss}</span><{heading}>{st}</{heading}><p>{sd}</p><a class="sf-button" href="{link}">{button}</a></div>{art}</div></div>')
controls=('<div class="sf-hero-controls"><button type="button" data-sf-slide-step="-1" aria-label="上一张横幅">‹</button><span class="sf-hero-count" aria-live="polite">1 / '+str(len(slides))+'</span><button type="button" data-sf-slide-step="1" aria-label="下一张横幅">›</button></div>' if len(slides)>1 else '')
parts.append(start+f'<div class="sf-hero-slider" data-sf-hero-slider><div class="sf-hero-track" tabindex="0" role="region" aria-label="横幅内容">{"".join(panels)}</div>{controls}</div>'+end)
continue
image=safe_media(resolve('image'))
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="lazy"></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
parts.append(start+'<div class="sf-hero-grid"><div class="sf-hero-copy"><span class="sf-kicker">'+subtitle+'</span><h1>'+title+'</h1><p>'+desc+'</p><a class="sf-button" href="'+safe_href(resolve('href','/product/'))+'">'+escape(resolve('button','探索更多'))+' <span>↗</span></a></div>'+art+'</div>'+end)
art=f'<div class="sf-hero-img"><img src="{image}" alt="{title}" loading="{'eager' if page.get('route')=='/' else 'lazy'}"{' fetchpriority="high"' if page.get('route')=='/' else ''}></div>' if image else '<div class="sf-hero-img sf-hero-abstract"><span>✧</span></div>'
variant=block.get('layout_variant','split')
fit=block.get('image_fit','cover')
visual_cls=(f'sf-hero-layout-{variant} sf-fit-{fit}' if 'layout_variant' in block or 'image_fit' in block else '')
legacy_action='<span>↗</span>' if not visual_cls else ''
parts.append(start+f'<div class="sf-hero-grid{(" "+visual_cls) if visual_cls else ""}"><div class="sf-hero-copy"><span class="sf-kicker">'+subtitle+'</span><h1>'+title+'</h1><p>'+desc+'</p><a class="sf-button" href="'+safe_href(resolve('href','/product/'))+'">'+escape(resolve('button','探索更多'))+legacy_action+'</a></div>'+art+'</div>'+end)
elif typ=='richtext':
body=sanitize(resolve('body'))
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-prose">{body}</div>'+end)
@@ -274,7 +325,10 @@ def render_blocks(blocks, site, page, entries):
# Homepage highlights are not catalogues: avoid injecting search/filters before featured cards.
# Other listing pages may opt into filters for sufficiently large result sets.
filterable=(page.get('route')!='/' and (page.get('type') in ('category','brand') or count>=9))
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div><a href="{safe_href(resolve("href","/product/"))}">{escape(resolve("button","查看全部"))}</a></div><div class="sf-collection" data-sf-filterable="{str(filterable).lower()}" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
card_style=block.get('card_style','standard')
image_fit=block.get('image_fit','contain')
card_classes=(' sf-card-style-'+card_style if 'card_style' in block else '')+(' sf-fit-'+image_fit if 'image_fit' in block else '')
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div><a href="{safe_href(resolve("href","/product/"))}">{escape(resolve("button","查看全部"))}</a></div><div class="sf-collection{card_classes}" data-sf-filterable="{str(filterable).lower()}" style="--sf-cols:{cols}">{cards if cards else "<p class=\"sf-muted\">当前暂无相关内容,请导入或添加数据。</p>"}</div>'+end)
elif typ=='image_text':
im=safe_media(resolve('image')); img=f'<img src="{im}" alt="{title}" loading="lazy">' if im else '<div class="sf-placeholder sf-large">✧</div>'
side=' sf-reverse' if block.get('image_side')=='left' else ''
@@ -288,7 +342,13 @@ def render_blocks(blocks, site, page, entries):
candidates=[(field['name'],field.get('label') or field['name']) for field in specs]
if not candidates:
candidates=[(k,k.replace('_',' ').title()) for k in props.keys() if k not in ('blocks','page_template','wp_metadata','wp_post_id','wp_type','fields') and isinstance(props[k],(str,int,float,bool))]
pairs=''.join(f'<div class="sf-attribute"><span>{escape(str(label))}</span><strong>{escape(str(props[key]))}</strong></div>' for key,label in candidates if props.get(key) not in (None,''))
source_mode=block.get('source_mode','page')
pairs=''
if source_mode in ('page','merge'):
pairs=''.join(f'<div class="sf-attribute"><span>{escape(str(label))}</span><strong>{escape(str(props[key]))}</strong></div>' for key,label in candidates if props.get(key) not in (None,''))
if source_mode in ('custom','merge'):
manual=''.join(f'<div class="sf-attribute"><span>{escape(resolve_text(item["label"],site,page))}</span><strong>{escape(resolve_text(item["value"],site,page))}</strong></div>' for item in block.get('items',[])[:30] if item.get('label') and item.get('value'))
pairs+=manual
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-attributes">{pairs if pairs else "<p class=\"sf-muted\">参数尚未填写</p>"}</div>'+end)
elif typ=='gallery':
pics=block.get('images') if isinstance(block.get('images'),list) else []
+36
View File
@@ -33,3 +33,39 @@
Restrict grid layout to its dedicated inner element. */
.sf-section.sf-features,.sf-section.sf-collection,.sf-section.sf-stats,.sf-section.sf-gallery,.sf-section.sf-attributes{display:block;grid-template-columns:none}
.sf-section.sf-faq{max-width:none}
/* V8.34: opt-in no-code compositions; no change for legacy blocks with no choice. */
.sf-hero-grid.sf-hero-layout-image-first > .sf-hero-img{order:-1}
.sf-hero-grid.sf-hero-layout-centered{grid-template-columns:minmax(0,1fr);text-align:center;justify-items:center}
.sf-hero-grid.sf-hero-layout-centered .sf-hero-copy{max-width:900px;margin-inline:auto}
.sf-hero-grid.sf-hero-layout-centered .sf-hero-img{width:100%;min-height:240px;max-height:540px}
.sf-hero-grid.sf-fit-contain .sf-hero-img img{object-fit:contain}
.sf-collection.sf-fit-contain .sf-item-img{background:#f4f7f9}
.sf-collection.sf-fit-contain .sf-item-img img{object-fit:contain}
.sf-collection.sf-card-style-compact .sf-item-img{height:156px}
.sf-collection.sf-card-style-compact .sf-item-copy{padding:16px 19px}
.sf-collection.sf-card-style-compact .sf-item-copy h3{font-size:clamp(16px,1.5vw,20px);margin:8px 0 5px}
.sf-collection.sf-card-style-compact .sf-item-copy p{font-size:13px;min-height:0}
.sf-collection.sf-card-style-showcase .sf-item-img{height:auto;aspect-ratio:16/10}
.sf-collection.sf-card-style-showcase .sf-item-copy{padding:25px}
.sf-collection.sf-card-style-showcase .sf-item-copy h3{font-size:clamp(19px,2vw,26px)}
@media(max-width:820px){.sf-hero-grid.sf-hero-layout-image-first > .sf-hero-img{order:initial}}
@media(max-width:540px){.sf-collection.sf-card-style-compact .sf-item-img{height:180px}.sf-hero-grid.sf-hero-layout-centered .sf-hero-img{min-height:200px}}
@media(prefers-reduced-motion:reduce){.sf-collection.sf-card-style-showcase .sf-item{transition:none}}
/* Industrial product photography respects *explicit* image fit, not archived defaults. */
.sf-theme-industrial .pro-custom-content .sf-collection.sf-fit-cover .sf-item-img img{object-fit:cover}
.sf-theme-industrial .pro-custom-content .sf-collection.sf-fit-contain .sf-item-img img{object-fit:contain}
/* V8.34.1 opt-in multi-Banner. No autoplay, low JS cost, native swipe and keyboard scroll. */
.sf-hero-slider{min-width:0}.sf-hero-track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;overscroll-behavior-x:contain;scrollbar-width:none}.sf-hero-track::-webkit-scrollbar{display:none}
.sf-hero-panel{flex:0 0 100%;width:100%;min-width:0;scroll-snap-align:start;scroll-snap-stop:always}
.sf-hero-controls{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:16px;color:inherit}
.sf-hero-controls button{border:1px solid currentColor;border-radius:50%;width:40px;height:40px;font-size:24px;line-height:1;background:transparent;color:inherit;cursor:pointer}
.sf-hero-controls button:focus-visible{outline:3px solid var(--accent,#3569d3);outline-offset:3px}
.sf-hero-count{font-size:13px;font-weight:700;min-width:40px;text-align:center}
@media(prefers-reduced-motion:reduce){.sf-hero-track{scroll-behavior:auto}}
.sf-hero-panel .sf-hero-copy h2{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}
+13
View File
@@ -171,3 +171,16 @@ body:not(.sf-advanced-mode) #tabStyle,body:not(.sf-advanced-mode) #tabResponsive
@media(max-width:720px){.sf-steps .sf-guide-link{margin-left:auto}.sf-inspector-mode{padding:8px 11px}}
.sf-photo-action strong{font-size:13px;color:#28516e}.sf-direct-upload{display:grid;gap:7px;color:#506b82;font-size:12px;font-weight:650}.sf-direct-upload input{font-size:11px;width:100%;max-width:100%}
/* V8.34: visible, understandable no-code layout presets */
.sf-visual-options{display:grid;grid-template-columns:1fr;gap:7px;margin-top:7px}
.sf-visual-option{display:flex;align-items:center;gap:12px;text-align:left;padding:10px 12px;background:#fff;border:1px solid #dce5ef;border-radius:9px;color:#354b62;font-size:12px;white-space:normal}
.sf-visual-option:hover{border-color:#8baee9;background:#f6f9ff}
.sf-visual-option.on{border-color:#366dc8;background:#eef5ff;color:#234d94;box-shadow:inset 3px 0 #366dc8}
.sf-option-mark{display:grid;place-items:center;flex:0 0 34px;width:34px;height:30px;border:1px solid #d9e5f6;border-radius:5px;background:#fff;color:#527bc1;font-size:20px}
.sf-visual-option:focus-visible{outline:3px solid #e0a43c;outline-offset:2px}
/* V8.34.1: compact human-readable slide/spec editors. */
.sf-slide-media{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:10px 0}
.sf-slide-media button,.sf-slide-media label{font-size:12px;border:1px solid #d6dee8;border-radius:7px;padding:8px 10px;cursor:pointer;background:#f9fbfd}
.sf-slide-media label input[type=file]{max-width:180px;display:block;margin-top:5px;font-size:11px}
+22 -12
View File
@@ -347,16 +347,16 @@ const Studio={
fieldQuiet(key,value){const b=blocks[selected];if(!b)return;b[key]=value;Studio.modified(true);const row=$('#blockList').querySelector(`[data-index="${selected}"]`);if(row)row.querySelector('small').textContent=b.title||b.subtitle||b.type},
arrayEditor(field,value,type){
const rows=Array.isArray(value)?value:[];
const labels={title:'标题',text:'文字介绍',value:'数字',label:'名称',question:'问题',answer:'回答',url:'图片地址',alt:'图片说明',image:'图片地址',caption:'图片说明',name:'名称',price:'价格',features:'方案包含的服务',href:'按钮跳转路径'};
const spec=schema.blocks[type];const sample=(spec?.defaults?.[field]||[])[0]||{url:'',alt:''};
const labels={title:'标题',text:'文字介绍',value:'数值',label:'参数名称',question:'问题',answer:'回答',url:'图片地址',alt:'图片说明',image:'图片地址',caption:'图片说明',name:'名称',price:'价格',features:'方案包含的服务',href:'按钮跳转路径',subtitle:'副标题',description:'说明文字',button:'按钮文字',image:'图片地址',value:'参数值'};
const spec=schema.blocks[type];const sample=(spec?.defaults?.[field]||[])[0]||(field==='slides'?{title:'',subtitle:'',description:'',image:'',button:'',href:'/product/'}:type==='attributes'?{label:'',value:''}:{url:'',alt:''});
const keys=Object.keys(sample);
return `<div class="sf-array-editor"><p class="sf-array-hint">无需填写 JSON:按顺序编辑每一项,修改会自动显示在预览中。</p>`+
rows.map((row,i)=>`<div class="sf-array-card"><div class="sf-array-card-head"><b>第 ${i+1} 项</b><div><button type="button" title="上移" ${i===0?'disabled':''} onclick="Studio.moveItem('${field}',${i},-1)">↑</button><button type="button" title="下移" ${i===rows.length-1?'disabled':''} onclick="Studio.moveItem('${field}',${i},1)">↓</button><button type="button" title="删除这一项" onclick="Studio.removeItem('${field}',${i})">删除</button></div></div>`+
keys.map(key=>`<label class="sf-array-field">${html(labels[key]||key)}${key==='text'||key==='answer'||key==='features'?`<textarea oninput="Studio.itemField('${field}',${i},'${key}',this.value)">${html(Array.isArray(row?.[key])?(row[key]||[]).join('\n'):row?.[key]??'')}</textarea>`:`<input type="text" value="${html(row?.[key]??'')}" oninput="Studio.itemField('${field}',${i},'${key}',this.value)">`}</label>`).join('')+
`</div>`).join('')+`<button type="button" class="sf-array-add" onclick="Studio.addItem('${field}')">+ 添加${field==='images'?'图片':'一项'}</button></div>`;
keys.map(key=>`<label class="sf-array-field">${html(labels[key]||key)}${key==='text'||key==='answer'||key==='features'||key==='description'?`<textarea oninput="Studio.itemField('${field}',${i},'${key}',this.value)">${html(Array.isArray(row?.[key])?(row[key]||[]).join('\n'):row?.[key]??'')}</textarea>`:`<input type="text" value="${html(row?.[key]??'')}" oninput="Studio.itemField('${field}',${i},'${key}',this.value)">`}</label>`).join('')+
`${field==='slides'?`<div class="sf-slide-media"><button type="button" onclick="Studio.chooseMedia('slides',${i})">从素材库换图</button><label>上传图片<input aria-label="上传第 ${i+1} 张横幅图片" type="file" accept="image/jpeg,image/png,image/webp,image/avif" onchange="Studio.uploadImage(this.files[0],'slides',${i});this.value=''"></label></div>`:''}</div>`).join('')+`<button type="button" class="sf-array-add" onclick="Studio.addItem('${field}')">+ 添加${field==='slides'?'一张横幅':field==='images'?'图片':'一项'}</button></div>`;
},
itemField(field,index,key,value){const block=blocks[selected],row=block?.[field]?.[index];if(!row)return;row[key]=key==='features'?String(value).split('\n').map(x=>x.trim()).filter(Boolean):String(value).slice(0,1500);Studio.modified(true)},
addItem(field){const block=blocks[selected];if(!block)return;const rows=block[field];if(!Array.isArray(rows))return;const limit=field==='images'?24:16;if(rows.length>=limit)return toast(`最多添加 ${limit} 项`,true);const sample=schema.blocks[block.type]?.defaults?.[field]?.[0]||{url:'',alt:''};const next={};for(const [key,val] of Object.entries(sample))next[key]=Array.isArray(val)?[]:'';rows.push(next);Studio.modified();Studio.inspector()},
addItem(field){const block=blocks[selected];if(!block)return;const rows=block[field];if(!Array.isArray(rows))return;const limit=field==='slides'?6:field==='images'?24:block.type==='attributes'?30:16;if(rows.length>=limit)return toast(`最多添加 ${limit} 项`,true);const sample=schema.blocks[block.type]?.defaults?.[field]?.[0]||(field==='slides'?{title:'',subtitle:'',description:'',image:'',button:'',href:'/product/'}:block.type==='attributes'?{label:'',value:''}:{url:'',alt:''});const next={};for(const [key,val] of Object.entries(sample))next[key]=Array.isArray(val)?[]:'';if(field==='slides'&&rows.length===0){for(const key of ['title','subtitle','description','image','button','href'])next[key]=String(block[key]||'')}rows.push(next);Studio.modified();Studio.inspector()},
removeItem(field,index){const rows=blocks[selected]?.[field];if(!Array.isArray(rows)||!rows[index])return;rows.splice(index,1);Studio.modified();Studio.inspector()},
moveItem(field,index,by){const rows=blocks[selected]?.[field],target=index+by;if(!Array.isArray(rows)||target<0||target>=rows.length)return;[rows[index],rows[target]]=[rows[target],rows[index]];Studio.modified();Studio.inspector()},
jsonField(key,value){try{let data=JSON.parse(value);if(!Array.isArray(data))throw Error('必须为数组');Studio.fieldQuiet(key,data);$('#jsonError').textContent='✓ JSON 格式有效';$('#jsonError').style.color='#238460'}catch(e){$('#jsonError').textContent='JSON 格式有误:'+e.message;$('#jsonError').style.color='#bf5a58'}},
@@ -370,10 +370,16 @@ const Studio={
toast('已应用区块外观,保存草稿前可以撤销');
},
inspector(){let target=$('#inspector');let b=blocks[selected];$('#blockTypeLabel').textContent=b?schema.blocks[b.type].name:'页面';if(!b){target.innerHTML='<div class="no-selection"><b>还没有选中内容</b><p>先到左侧选择一个区块,或点击“添加内容区块”。</p><button type="button" class="ui-btn primary" onclick="Studio.toggleLibrary(true)">+ 添加内容</button></div>';return}const spec=schema.blocks[b.type];let out=`<div class="inspector-title"><h3>${html(spec.name)}</h3><label class="toggle"><input type="checkbox" ${b.visible!==false?'checked':''} onchange="Studio.field('visible',this.checked)">显示</label></div><div data-inspector-section="content">`;
for(const field of spec.fields.filter(f=>b.type!=='layout'||!['columns','gap','tablet_columns','mobile_columns'].includes(f))){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度'})[field]||field;
for(const field of spec.fields.filter(f=>(b.type!=='layout'||!['columns','gap','tablet_columns','mobile_columns'].includes(f))&&!(b.type==='hero'&&b.slides?.length&&['title','subtitle','description','image','button','href'].includes(f))&&!(b.type==='attributes'&&(b.source_mode||'page')==='page'&&f==='items'))){let value=b[field]??'',label=({title:'标题',subtitle:'副标题',description:'简介',image:'图片地址',body:'正文 HTML',button:'按钮文字',href:'跳转路径',source:'绑定内容类型',limit:'显示条数',columns:'每行列数',items:'重复条目',images:'画廊图片',image_side:'图片方向',align:'标题对齐',height:'间隔高度',layout_variant:'Banner 排版',card_style:'卡片展示方式',image_fit:'图片显示方式',slides:'多图 Banner(可选)',source_mode:'参数内容来源'})[field]||field;
out+=`<div class="prop"><label>${html(label)}</label>`;
if(field==='items'||field==='images')out+=Studio.arrayEditor(field,value,b.type);
if(field==='items'||field==='images'||field==='slides')out+=(field==='slides'?'<p class="prop-tip">首次添加会复制当前 Banner;之后直接修改每张图的文字、按钮和图片。最多 6 张,访客手动切换。删除全部幻灯片后恢复原来的单图 Banner。</p>':field==='items'&&b.type==='attributes'?'<p class="prop-tip">可以手动添加参数,不需要填写 JSON;仅在选择“手动参数”或“数据 + 补充”时显示。</p>':'')+Studio.arrayEditor(field,value,b.type);
else if(field==='source_mode')out+=`<select onchange="Studio.field('${field}',this.value);Studio.inspector()">${[['page','从产品数据自动读取'],['custom','只显示手动填写'],['merge','产品数据 + 手动补充']].map(([v,n])=>`<option value="${v}" ${value===v?'selected':''}>${n}</option>`).join('')}</select><small class="prop-tip">默认读取产品数据,不会更改原产品信息。</small>`;
else if(field==='source')out+=`<select onchange="Studio.field('${field}',this.value)">${['product','post','page','vehicle','brand','case','service','other','all'].map(t=>`<option value="${t}" ${t===value?'selected':''}>${({product:'产品',post:'文章',page:'普通页面',vehicle:'车型',brand:'品牌',case:'案例',service:'服务',other:'其他',all:'全部类型'})[t]}</option>`).join('')}</select><span class="prop-tip">网站已有内容将自动绑定并生成卡片。</span>`;
else if(['layout_variant','card_style','image_fit'].includes(field)){
const choices={layout_variant:[['split','文字左侧 · 图片右侧'],['image-first','图片左侧 · 文字右侧'],['centered','标题居中 · 图片下方']],card_style:[['standard','标准卡片'],['compact','紧凑型 · 展示更多产品'],['showcase','图片优先 · 大图展示']],image_fit:[['cover','铺满画面(可能裁切)'],['contain','完整显示(不裁切)']]}[field];
out+=`<div class="sf-visual-options" role="group" aria-label="${html(label)}">${choices.map(([v,name])=>`<button type="button" class="sf-visual-option ${value===v?'on':''}" aria-pressed="${String(value===v)}" onclick="Studio.field('${field}','${v}');Studio.inspector()"><span class="sf-option-mark" aria-hidden="true">${v==='split'?'▤':v==='image-first'?'▥':v==='centered'?'▦':v==='compact'?'▤':v==='showcase'?'▧':v==='contain'?'▣':'▦'}</span><span>${html(name)}</span></button>`).join('')}</div>`;
if(field==='image_fit')out+='<small class="prop-tip">汽车与管材产品建议选择“完整显示”,避免产品轮廓被裁切。</small>';
}
else if(field==='image_side'||field==='align')out+=`<select onchange="Studio.field('${field}',this.value)">${(field==='align'?['left','center']:['left','right']).map(v=>`<option value="${v}" ${value===v?'selected':''}>${v==='left'?'左侧':v==='right'?'右侧':'居中'}</option>`).join('')}</select>`;
else if(field==='body'||field==='description')out+=`<textarea onchange="Studio.fieldQuiet('${field}',this.value)">${html(value)}</textarea>`;
else if(['limit','columns','height'].includes(field))out+=`<input type="number" min="1" max="120" value="${html(value)}" onchange="Studio.fieldQuiet('${field}',Number(this.value))">`;
@@ -383,7 +389,7 @@ const Studio={
}
const mediaTarget=selectedChildId?editorTools.findChildInRoot(blocks,b.id,selectedChildId)?.child:b;
const mediaFields=schema.blocks[mediaTarget?.type]?.fields||[];
if(mediaTarget&&(['gallery','carousel'].includes(mediaTarget.type)||mediaFields.includes('image')))out+=`<div class="sf-photo-action"><strong>图片设置</strong><button class="ui-btn ghost" type="button" onclick="Studio.chooseMedia()">从图片素材库选择</button><label class="sf-direct-upload">或上传一张新图片<input aria-label="上传新图片" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0]);this.value=''"></label><small>预览先显示在草稿里,点击保存后才会保留。</small></div>`;
if(mediaTarget&&!(mediaTarget.type==='hero'&&mediaTarget.slides?.length)&&(['gallery','carousel'].includes(mediaTarget.type)||mediaFields.includes('image')))out+=`<div class="sf-photo-action"><strong>图片设置</strong><button class="ui-btn ghost" type="button" onclick="Studio.chooseMedia()">从图片素材库选择</button><label class="sf-direct-upload">或上传一张新图片<input aria-label="上传新图片" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onchange="Studio.uploadImage(this.files[0]);this.value=''"></label><small>预览先显示在草稿里,点击保存后才会保留。</small></div>`;
if(b.type==='layout')out+=Studio.childQuickEditor(b);
out+='</div><div data-inspector-section="responsive">';
if(b.type==='layout'){
@@ -619,20 +625,24 @@ const Studio={
removeChild(i,j){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks.splice(j,1);Studio.modified();Studio.inspector()},
childField(i,j,key,value){const b=blocks[selected];if(!b||b.type!=='layout')return;b.columns[i].blocks[j][key]=value;Studio.modified()},
moveChild(ci,ri,target){const b=blocks[selected];if(!b||b.type!=='layout'||target<0||target>=b.columns.length)return;let [component]=b.columns[ci].blocks.splice(ri,1);b.columns[target].blocks.push(component);Studio.modified();Studio.inspector()},
async chooseMedia(){try{const data=await api(`/api/sites/${siteId}/media`);const grid=$('#siteMediaGrid');grid.replaceChildren();if(!data.length){grid.textContent='暂无上传图片。可先在当前组件中上传图片。'}for(const item of data){const button=document.createElement('button');button.type='button';button.style.cssText='border:1px solid #e2e8f0;border-radius:9px;background:white;padding:7px;cursor:pointer';const img=document.createElement('img');img.alt=item.name;img.loading='lazy';img.style.cssText='width:100%;height:105px;object-fit:contain';img.src=`/local-media/${encodeURIComponent(siteId)}/${encodeURIComponent(item.name)}`;const label=document.createElement('small');label.textContent=item.name;label.style.cssText='display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';button.append(img,label);button.addEventListener('click',()=>Studio.applyMedia(item.url));grid.append(button)}$('#siteMediaModal').showModal()}catch(e){toast(e.message,true)}},
applyMedia(url){
async chooseMedia(field=null,index=null){try{const data=await api(`/api/sites/${siteId}/media`);const grid=$('#siteMediaGrid');grid.replaceChildren();if(!data.length){grid.textContent='暂无上传图片。可先在当前组件中上传图片。'}for(const item of data){const button=document.createElement('button');button.type='button';button.style.cssText='border:1px solid #e2e8f0;border-radius:9px;background:white;padding:7px;cursor:pointer';const img=document.createElement('img');img.alt=item.name;img.loading='lazy';img.style.cssText='width:100%;height:105px;object-fit:contain';img.src=`/local-media/${encodeURIComponent(siteId)}/${encodeURIComponent(item.name)}`;const label=document.createElement('small');label.textContent=item.name;label.style.cssText='display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';button.append(img,label);button.addEventListener('click',()=>Studio.applyMedia(item.url,field,index));grid.append(button)}$('#siteMediaModal').showModal()}catch(e){toast(e.message,true)}},
applyMedia(url,field=null,index=null){
const root=blocks[selected];if(!root)return;
const found=selectedChildId?editorTools.findChildInRoot(blocks,root.id,selectedChildId):null;
if(selectedChildId&&!found)return toast('当前子组件已失效,请重新选中后添加图片',true);
const block=found?.child||root;
const fields=schema?.blocks?.[block.type]?.fields||[];
if(block.type==='gallery')block.images=[...(block.images||[]),{url,alt:''}];
if(field==='slides'){
if(block.type!=='hero'||!Number.isInteger(index)||index<0||index>=(block.slides||[]).length)return toast('所选横幅不存在,请重新选择',true);
block.slides[index].image=url;
}
else if(block.type==='gallery')block.images=[...(block.images||[]),{url,alt:''}];
else if(block.type==='carousel')block.items=[...(block.items||[]),{image:url,alt:'',caption:''}];
else if(fields.includes('image'))block.image=url;
else return toast('选中的组件不支持图片,请先选择一个图片组件',true);
Studio.modified();Studio.draw();$('#siteMediaModal').close();toast('已应用素材到草稿');
},
async uploadImage(file){if(!file)return;try{const form=new FormData();form.append('file',file);let r=await api(`/api/sites/${siteId}/media`,{method:'POST',body:form});Studio.applyMedia(r.url);toast('图片已上传,保存页面后参与构建')}catch(e){toast(e.message,true)}},
async uploadImage(file,field=null,index=null){if(!file)return;try{const form=new FormData();form.append('file',file);let r=await api(`/api/sites/${siteId}/media`,{method:'POST',body:form});if(field==='slides')Studio.applyMedia(r.url,field,index);else Studio.applyMedia(r.url);toast('图片已上传,保存页面后参与构建')}catch(e){toast(e.message,true)}},
async buildSite(){if(dirty||draftRevision||siteDesignRevision)return toast('有尚未发布的页面或全站草稿,请先发布,再构建网站',true);try{
toast('已提交后台构建任务');const task=await api(`/api/sites/${siteId}/builds`,req('POST',{mirror_images:false}));
for(let i=0;i<240;i++){
+10
View File
@@ -2,6 +2,16 @@
(()=>{
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const menu=$('.sf-menu-toggle');if(menu){menu.addEventListener('click',()=>{let open=menu.getAttribute('aria-expanded')!=='true';menu.setAttribute('aria-expanded',String(open));$('#sf-primary-links')?.classList.toggle('is-open',open);});}
// V8.34.1: accessible, manual carousel. Native scroll/swipe works without JS.
$$('.sf-hero-slider[data-sf-hero-slider]').forEach(slider=>{
const track=slider.querySelector('.sf-hero-track');const panels=[...slider.querySelectorAll('.sf-hero-panel')];
if(!track||panels.length<2)return;
const counter=slider.querySelector('.sf-hero-count');let index=0;
const reduced=window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
function choose(next){index=(next+panels.length)%panels.length;track.scrollTo({left:panels[index].offsetLeft-panels[0].offsetLeft,behavior:reduced?'instant':'smooth'});if(counter)counter.textContent=`${index+1} / ${panels.length}`;}
slider.querySelectorAll('[data-sf-slide-step]').forEach(btn=>btn.addEventListener('click',()=>choose(index+Number(btn.dataset.sfSlideStep))));
let raf=false;track.addEventListener('scroll',()=>{if(raf)return;raf=true;requestAnimationFrame(()=>{const left=track.scrollLeft;let best=0,delta=Infinity;panels.forEach((panel,i)=>{const d=Math.abs(panel.offsetLeft-panels[0].offsetLeft-left);if(d<delta){best=i;delta=d;}});index=best;if(counter)counter.textContent=`${index+1} / ${panels.length}`;raf=false;});},{passive:true});
});
// Filter actual static content cards, no API is needed at runtime.
$$('.sf-collection[data-sf-filterable="true"]').forEach(grid=>{
const cards=$$('.sf-item',grid);
+1 -1
View File
@@ -1,5 +1,5 @@
"""Single source of truth for the SiteForge platform release identity."""
import os
VERSION = "8.33.1-dev.1"
VERSION = "8.34.1-dev.1"
PLATFORM_NAME = "SiteForge Factory"
BUILD_SHA = os.environ.get("SITEFORGE_GIT_SHA", "unknown").strip()[:40] or "unknown"