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:
浅念
2026-10-29 13:49:08 +08:00
parent 3ce4292794
commit f5dc2f7624
5 changed files with 311 additions and 84 deletions
+103 -2
View File
@@ -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,