Task 15: product detail anchor tabs + per-product custom sections
User report: product detail page was wrong — section titles are
per-product custom data (CMS wppop_sections migration: Packing &
Delivery / Our Exhibition / Our Certifications / Our Company / FAQ),
not fixed headings; they must drive an anchor tab system like the
CMS [locale]/products/[slug].astro detailTabs.
Data side (both layers, task option 1 + 2):
- theme_pro.product_detail_sections: fields.sections
[{title, content}] JSON is the structured source; legacy sync
output that appended sections as trailing body H2 blocks is
recognized by known/data titles and split out (overview keeps the
rest; same-title blocks dedupe to the structured version; Features
body block drops when fields.features renders it)
- sync_cms_data.build_product: store sections/features as structured
fields JSON, stop appending H2 blocks to the body; stats now count
entries with a sections field
- Specifications table no longer leaks structural JSON field keys
(sections/features/specs/gallery/categories) — scalar spec keys
only
Template side (trade-pro product.html, replaces 12a panel
switching):
- tab sequence is fully data-driven and matches the CMS: Product
Details (always) -> custom sections (data titles) ->
Specifications / Features (shown only when data exists)
- tabs are #anchor links, not panel toggles: all sections render
flat with visible titles (readable and crawlable without JS; native
anchor jump), JS adds smooth scrolling + scrollspy highlight +
tab centering in the horizontal scroll row
- section ids are slug-derived (sec-packing-delivery), duplicate
titles get an index suffix; features grid ports the CMS
detail-feature-row
Tests: test_trade_pro_product_layout_structure rewritten for the
anchor-tab contract — sections via fields, legacy body-H2 fallback,
spec/features conditional tabs, no-sections product showing only
Product Details + Specifications, spec table free of JSON key leaks,
updated pro.css needles. 30 passed; the 1 failure
(test_trade_pro_blocks_visual_bridge) fails identically on the
previous commit (pre-existing, unrelated).
Co-Authored-By: Claude Code <noreply@example.com>
This commit is contained in:
+103
-2
@@ -8,7 +8,7 @@ import hashlib
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from html import escape
|
||||
from html import escape, unescape
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
@@ -122,6 +122,95 @@ def media_gallery(page):
|
||||
if len(results)>=20:break
|
||||
return results
|
||||
|
||||
# ── 产品详情自定义区块(CMS wppop_sections 迁移:Packing & Delivery / Our
|
||||
# Exhibition / Our Certifications / Our Company / FAQ——标题来自每产品数据,
|
||||
# 不固定)。数据侧两个来源(Task 15):
|
||||
# 1) fields.sections:同步脚本结构化写入的 [{title, content}] JSON;
|
||||
# 2) 旧同步产物把区块追加进 body 尾部 <h2> 段——按标题识别拆出为独立区块,
|
||||
# 避免正文面板与区块面板重复渲染。正文自有的其他 H2 标题不受影响。──
|
||||
KNOWN_SECTION_TITLES=frozenset((
|
||||
'packing & delivery','our exhibition','our certifications','our company','faq','features',
|
||||
))
|
||||
_H2_RE=re.compile(r'<h2\b[^>]*>([\s\S]*?)</h2>',re.I)
|
||||
|
||||
def plain_text(fragment):
|
||||
"""HTML 片段 → 单行纯文本(标签剥离 + 实体解码),标题比对用。"""
|
||||
return ' '.join(unescape(re.sub(r'<[^>]+>',' ',fragment or '')).split()).strip()
|
||||
|
||||
def parse_pairs(value,keys):
|
||||
"""fields 里的 JSON 列表(sections/features)→ [{'title','content'|'desc'}]。
|
||||
|
||||
容忍 JSON 字符串与已解析列表两种形态;缺标题或缺内容的条目丢弃,上限 12 条。
|
||||
"""
|
||||
if isinstance(value,str):
|
||||
try:value=json.loads(value)
|
||||
except (TypeError,ValueError):return []
|
||||
if not isinstance(value,list):return []
|
||||
out=[]
|
||||
for item in value:
|
||||
if not isinstance(item,dict):continue
|
||||
title=str(item.get('title') or '').strip()
|
||||
payload=str(item.get(keys[1]) or '').strip()
|
||||
if title and payload:
|
||||
out.append({'title':title,keys[1]:payload})
|
||||
return out[:12]
|
||||
|
||||
def split_body_sections(body,wanted):
|
||||
"""按 H2 标题把 body 拆为 (overview_html, sections)。
|
||||
|
||||
wanted 为小写标题集合:命中的 H2 与其后到下一个 H2 前的内容拆出为区块;
|
||||
未命中的 H2 与其余内容原样留在 overview(正文自有标题不误拆)。连续命中
|
||||
依次成段;空内容区块不拆(保留原状)。
|
||||
"""
|
||||
matches=list(_H2_RE.finditer(body or ''))
|
||||
overview_parts,sections,last=[],[],0
|
||||
for i,m in enumerate(matches):
|
||||
title=plain_text(m.group(1))
|
||||
if title.casefold() not in wanted:continue
|
||||
end=matches[i+1].start() if i+1<len(matches) else len(body or '')
|
||||
content=(body[m.end():end] or '').strip()
|
||||
if not content:continue
|
||||
overview_parts.append(body[last:m.start()])
|
||||
sections.append({'title':title,'content':content})
|
||||
last=end
|
||||
if not sections:return body,[]
|
||||
overview_parts.append(body[last:])
|
||||
return ''.join(overview_parts),sections
|
||||
|
||||
def section_ids(sections):
|
||||
"""区块锚点 id:sec-<slug(title)>;同名标题追加序号保唯一(sec-faq-1)。"""
|
||||
used={}
|
||||
out=[]
|
||||
for sec in sections:
|
||||
base='sec-'+(slug(sec['title']) or 'section')
|
||||
n=used.get(base,0)
|
||||
used[base]=n+1
|
||||
out.append(base if n==0 else f'{base}-{n}')
|
||||
return out
|
||||
|
||||
def product_detail_sections(page,attrs):
|
||||
"""产品详情 Tab 区块数据:[(id, title, content HTML)] + features 列表。
|
||||
|
||||
fields.sections(结构化)优先,body H2 识别兜底;两者都有的同名区块只保留
|
||||
结构化版本。Features:fields.features 有数据时 body 里的同名 H2 段拆出丢弃
|
||||
(由结构化数据渲染,防重复)。
|
||||
"""
|
||||
structured=parse_pairs(attrs.get('sections'),('title','content'))
|
||||
features=parse_pairs(attrs.get('features'),('title','desc'))
|
||||
covered={s['title'].casefold() for s in structured}
|
||||
if features:covered.add('features')
|
||||
wanted=set(KNOWN_SECTION_TITLES)|covered
|
||||
remainder,body_sections=split_body_sections(str(page.get('body') or ''),wanted)
|
||||
merged=list(structured)
|
||||
for sec in body_sections:
|
||||
if sec['title'].casefold() not in covered:
|
||||
merged.append(sec)
|
||||
covered.add(sec['title'].casefold())
|
||||
merged=merged[:12]
|
||||
return remainder,[
|
||||
{'id':sid,'title':sec['title'],'content':Markup(sanitize(sec['content']))}
|
||||
for sid,sec in zip(section_ids(merged),merged)],features
|
||||
|
||||
def select_listing(page,kind,groups,all_records,requested):
|
||||
if kind=='category':
|
||||
category=text(page,'category') or text(page,'title')
|
||||
@@ -157,7 +246,11 @@ def make_context(site,page,records,mode='detail',listing_items=None,prepared=Non
|
||||
if listing_items is not None and mode=='listing':
|
||||
listing=list(listing_items)
|
||||
detail_fields=fields(pg)
|
||||
spec=[{'label':str(k).replace('_',' ').capitalize(),'value':str(v)} for k,v in detail_fields.items() if isinstance(v,(str,int,float)) and v not in ('',None) and k not in ('brand','category','model','description','image','cover','url')]
|
||||
# 结构化 JSON/列表字段(同步脚本写入的 sections/features/specs/gallery 等
|
||||
# 附件数据)不进 Specifications 表——表中只列真正的标量规格键。
|
||||
STRUCTURED_FIELD_KEYS=('brand','category','model','description','image','cover','url',
|
||||
'gallery','sections','features','specs','categories')
|
||||
spec=[{'label':str(k).replace('_',' ').capitalize(),'value':str(v)} for k,v in detail_fields.items() if isinstance(v,(str,int,float)) and v not in ('',None) and k not in STRUCTURED_FIELD_KEYS]
|
||||
ownbrand=str(detail_fields.get('brand') or pg.get('title') or '').strip()
|
||||
related=[x for x in groups['product']+groups['vehicle'] if str(fields(x).get('brand') or '').casefold()==ownbrand.casefold()] if kind=='brand' else []
|
||||
settings=json.loads(site.get('settings_json') or '{}') if isinstance(site.get('settings_json'),str) else site.get('settings_json',{}) or {}
|
||||
@@ -180,7 +273,15 @@ def make_context(site,page,records,mode='detail',listing_items=None,prepared=Non
|
||||
if chrome['nav']:nav=chrome['nav']
|
||||
form_endpoint=settings.get('form_endpoint') or ''
|
||||
form_endpoint=form_endpoint if isinstance(form_endpoint,str) and form_endpoint.startswith('https://') and not any(x in form_endpoint for x in ('<','>','\"','\n')) else ''
|
||||
# 产品详情自定义区块(Task 15):fields.sections 结构化优先,body H2 识别
|
||||
# 兜底;overview 为拆出区块后的剩余正文(产品模板专用,content 保持全量
|
||||
# 供其他主题继续整体渲染)。
|
||||
overview_html=pg.get('body') or ''
|
||||
detail_sections=[];detail_features=[]
|
||||
if kind=='product':
|
||||
overview_html,detail_sections,detail_features=product_detail_sections(pg,detail_fields)
|
||||
return {'year':datetime.datetime.now(datetime.timezone.utc).year,'theme_color':{'automotive':'#0e1c2c','industrial':'#113f59','corporate':'#f5f6fa','minimal':'#f8f7f3','trade-pro':'#0b3d91'}.get(site.get('theme'),'#ffffff'),'gallery':media_gallery(pg),'form_endpoint':form_endpoint,'site':site,'page':pg,'kind':kind,'records':records,'groups':groups,'listing':listing,
|
||||
'detail_sections':detail_sections,'detail_features':detail_features,'detail_overview':Markup(sanitize(overview_html)),
|
||||
'showcase':groups['product'][:6]+groups['vehicle'][:max(0,6-len(groups['product'][:6]))],
|
||||
'features':groups['service'][:3], 'stories':groups['post'][:3], 'cases':groups['case'][:3],
|
||||
'brands':assets['brands'],'categories':assets['categories'], 'nav':nav,
|
||||
|
||||
Reference in New Issue
Block a user