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,
|
||||
|
||||
+15
-10
@@ -16,6 +16,10 @@ SITE 常量(字面量,直接文本解析)。
|
||||
- 正文复刻 wp-text.ts 的 cleanWpHtml/wrapInlineInP/htmlToExcerpt 清洗
|
||||
WordPress 残留(Gutenberg 注释、caption 短码、表单区块、H1 降级等),
|
||||
SiteForge 侧 bleach 白名单再剥离 aligncenter 等类名/内联样式;
|
||||
- 产品详情区块(CMS wppop_sections 迁移,每产品自定义标题)以
|
||||
[{title, content}] JSON 存 fields.sections,不追加进 body——主题层渲染
|
||||
为锚点 Tab 区块(theme_pro.product_detail_sections;旧同步产物追加进
|
||||
body 的 H2 段由主题层识别拆出);
|
||||
- 站点配置:site.ts 的 name/tagline + locale=en(PUT /api/i18n/.../locale)。
|
||||
|
||||
用法:
|
||||
@@ -341,14 +345,11 @@ def build_product(slug: str, entry: dict, media: MediaSync) -> dict:
|
||||
if isinstance(c, dict) and _s(c.get("slug"))
|
||||
]
|
||||
|
||||
body_html = raw_desc
|
||||
for sec in sections:
|
||||
body_html += f"\n\n<h2>{sec['title']}</h2>\n{sec['content']}"
|
||||
if features:
|
||||
body_html += "\n\n<h2>Features</h2>\n"
|
||||
for f in features:
|
||||
body_html += f"\n<h3>{f['title']}</h3>\n<p>{f['desc']}</p>\n"
|
||||
body_html = media.rewrite_body(body_html)
|
||||
# 详情区块/特性存结构化 fields(Task 15):正文 body 只放 description,
|
||||
# sections/features 以 [{title, content|desc}] JSON 进 fields——主题层
|
||||
# (theme_pro.product_detail_sections)据此渲染每产品自定义锚点 Tab 区块,
|
||||
# 不再把区块标题追加进 body(旧产物由主题层按 body H2 段识别拆出)。
|
||||
body_html = media.rewrite_body(raw_desc)
|
||||
|
||||
fields: dict = {}
|
||||
if _s(data.get("brand")):
|
||||
@@ -357,6 +358,8 @@ def build_product(slug: str, entry: dict, media: MediaSync) -> dict:
|
||||
fields["color"] = _s(data["color"])
|
||||
if _s(data.get("standard")):
|
||||
fields["standard"] = _s(data["standard"])
|
||||
if sections:
|
||||
fields["sections"] = json.dumps(sections, ensure_ascii=False)
|
||||
if categories:
|
||||
fields["category"] = categories[0]["name"]
|
||||
fields["categories"] = json.dumps(categories, ensure_ascii=False)
|
||||
@@ -720,7 +723,7 @@ def main() -> int:
|
||||
|
||||
final = sf.entries(args.site)
|
||||
per_type: dict[str, int] = {}
|
||||
cover_media = gallery_fields = spec_fields = 0
|
||||
cover_media = gallery_fields = spec_fields = sections_fields = 0
|
||||
for e in final:
|
||||
per_type[e["type"]] = per_type.get(e["type"], 0) + 1
|
||||
f = (e.get("meta") or {}).get("fields") or {}
|
||||
@@ -730,6 +733,8 @@ def main() -> int:
|
||||
gallery_fields += 1
|
||||
if f.get("specs"):
|
||||
spec_fields += 1
|
||||
if f.get("sections"):
|
||||
sections_fields += 1
|
||||
|
||||
print("\n===== 同步统计 =====")
|
||||
for t in types:
|
||||
@@ -738,7 +743,7 @@ def main() -> int:
|
||||
f"updated={s['updated']} failed={s['failed']} site_total={per_type.get(t, 0)}")
|
||||
print(f"媒体上传:{media.uploads} 张(缓存命中 {media.hits})")
|
||||
print(f"全站 media 封面:{cover_media} | 含 gallery 字段:{gallery_fields} "
|
||||
f"| 含 specs 字段:{spec_fields}")
|
||||
f"| 含 specs 字段:{spec_fields} | 含 sections 字段:{sections_fields}")
|
||||
if media.failures:
|
||||
print(f"媒体失败 {len(media.failures)} 项(前 10):")
|
||||
for f in media.failures[:10]:
|
||||
|
||||
@@ -182,25 +182,41 @@ def test_trade_pro_home_layout_structure(factory):
|
||||
|
||||
|
||||
def test_trade_pro_product_layout_structure(factory):
|
||||
# Task 12a:product.html 按 cms [slug].astro 精确对齐重做后的结构契约。
|
||||
# 构建流(/preview/ HTTP)+ 带 fields/gallery 的产品条目驱动断言:
|
||||
# hero 画廊(pro.js 钩子)+ 标题区(cms 产品页无 h1,产品名 h2 量级);
|
||||
# sticky 选项卡导航 + 面板(overview 恒有 / specifications 有数据才显);
|
||||
# 无 JS 平铺回退(静态标记不带 tabs-enabled、面板无内联隐藏);
|
||||
# rich-body 正文表格样式 + spec-table 两对键值一行 + 面板标题品牌色下划短线;
|
||||
# 询盘/相关产品沿用既有实现;旧骨架 .pro-product-layout/.pro-specs 无残留。
|
||||
# Task 15:product.html 锚点选项卡 + 每产品自定义区块(重做 12a 面板切换)。
|
||||
# Tab 序列对齐 cms detailTabs:Product Details(正文)→ 自定义区块
|
||||
# (wppop_sections 迁移,标题来自每产品数据不固定)→ Specifications →
|
||||
# Features(后两者按数据显隐)。Tab 为锚点跳转非面板切换:区块全部平铺
|
||||
# 渲染、panel-title 常显(无 JS 原生锚点跳转可读、静态可抓取);JS 渐进
|
||||
# 增强平滑滚动 + scrollspy 高亮。区块数据双来源:fields.sections 结构化
|
||||
# 优先;旧同步产物 body 尾部 H2 段按标题识别拆出(正文不重复渲染)。
|
||||
sid = site(factory)
|
||||
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
|
||||
made = factory.post(f'/api/sites/{sid}/entries', json={
|
||||
'type': 'product', 'title': 'Hydraulic Pump HP-200', 'route': '/product/hp-200/',
|
||||
'cover': 'https://imgs.example/hp-200.jpg', 'summary': 'High-pressure hydraulic pump for export.',
|
||||
'body': '<p>HP-200 body copy with export-grade tolerances.</p>',
|
||||
'meta': {'fields': {'brand': 'Quanta', 'range': '500 km', 'pressure': '210 bar',
|
||||
'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg']}}})
|
||||
'gallery': ['https://imgs.example/hp-200.jpg', 'https://imgs.example/hp-200-b.jpg'],
|
||||
'sections': json.dumps([
|
||||
{'title': 'Packing & Delivery', 'content': '<p>Export grade packing on fumigated pallets.</p>'},
|
||||
{'title': 'FAQ', 'content': '<p>Lead time is 20 days after deposit.</p>'}]),
|
||||
'features': json.dumps([
|
||||
{'title': 'High pressure', 'desc': '210 bar continuous rating.'},
|
||||
{'title': 'Export packing', 'desc': 'Fumigated pallets and desiccant.'}])}}})
|
||||
assert made.status_code in (200, 201), made.text
|
||||
other = factory.post(f'/api/sites/{sid}/entries', json={
|
||||
'type': 'product', 'title': 'Companion Valve', 'route': '/product/valve/',
|
||||
'summary': 'Matching control valve.'})
|
||||
'summary': 'Matching control valve.',
|
||||
'meta': {'fields': {'material': 'Stainless steel'}}})
|
||||
assert other.status_code in (200, 201), other.text
|
||||
# 旧同步产物形态:区块以 H2 段追加在 body 尾部(无 fields.sections)
|
||||
legacy = factory.post(f'/api/sites/{sid}/entries', json={
|
||||
'type': 'product', 'title': 'Drainage Pipe DN200', 'route': '/product/dn200/',
|
||||
'summary': 'Corrugated drainage pipe.',
|
||||
'body': '<p>DN200 intro paragraph stays in overview.</p>'
|
||||
'<h2>Packing & Delivery</h2><p>Bundled in 6 m lengths.</p>'
|
||||
'<h2>Our Exhibition</h2><p>Canton Fair booth photos.</p>'})
|
||||
assert legacy.status_code in (200, 201), legacy.text
|
||||
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
|
||||
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
|
||||
r = factory.get(f'/preview/{sid}/{bid}/product/hp-200/')
|
||||
@@ -217,30 +233,40 @@ def test_trade_pro_product_layout_structure(factory):
|
||||
assert soup.select_one('.sf-tp-product-summary h1.sf-tp-product-title')
|
||||
assert len(soup.find_all('h1')) == 1 # 平台单 h1 契约(与 test_theme_pro_v3 一致)
|
||||
assert soup.select_one('.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]')
|
||||
# 选项卡导航:sticky 容器 + 单行横滚 tabs-row;首个 active,第二个有 specs 才显
|
||||
# 锚点选项卡:Tab 为 #anchor 链接,序列数据驱动(overview 恒有 → 自定义区块
|
||||
# → Specifications/Features 按数据显隐);首个 active,其余 aria-selected=false
|
||||
tabs = soup.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
|
||||
assert len(tabs) == 2
|
||||
assert 'Product Details' in tabs[0].get_text() and 'Specifications' in tabs[1].get_text()
|
||||
assert tabs[0].get('data-panel') == 'overview' and tabs[1].get('data-panel') == 'specifications'
|
||||
assert tabs[0].get('aria-selected') == 'true' and tabs[1].get('aria-selected') == 'false'
|
||||
# 面板结构:overview 恒有(正文)+ specifications(specs 数据);首面板 active
|
||||
panels = soup.select('.sf-tp-product-tabbed .sf-tp-panel')
|
||||
assert len(panels) == 2
|
||||
assert panels[0].get('id') == 'overview' and 'active' in panels[0].get('class', [])
|
||||
assert panels[1].get('id') == 'specifications'
|
||||
# 无 JS 平铺回退:tabs-enabled 只由脚本运行时添加(静态标记不带),
|
||||
# 面板无内联 display 隐藏;panel-title 保留在标记中(由 CSS 类控制隐藏)
|
||||
tabbed_el = soup.select_one('[data-sf-tp-tabbed]')
|
||||
assert tabbed_el is not None and 'sf-tp-tabs-enabled' not in (tabbed_el.get('class') or [])
|
||||
assert all(p.get('style') is None for p in panels)
|
||||
assert len(tabs) == 5
|
||||
assert [t.get('href') for t in tabs] == ['#overview', '#sec-packing-delivery',
|
||||
'#sec-faq', '#specifications', '#features']
|
||||
assert 'Product Details' in tabs[0].get_text()
|
||||
assert 'Packing & Delivery' in tabs[1].get_text() and 'FAQ' in tabs[2].get_text()
|
||||
assert 'Specifications' in tabs[3].get_text() and 'Features' in tabs[4].get_text()
|
||||
assert tabs[0].name == 'a' and tabs[0].get('aria-selected') == 'true'
|
||||
assert all(t.get('aria-selected') == 'false' for t in tabs[1:])
|
||||
assert 'active' in (tabs[0].get('class') or [])
|
||||
# 自定义区块:锚点 section + panel-title 标题(来自数据)+ rich-body 内容
|
||||
packing = soup.select_one('#sec-packing-delivery.sf-tp-detail-section')
|
||||
assert packing is not None
|
||||
assert packing.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'Packing & Delivery'
|
||||
assert 'Export grade packing on fumigated pallets.' in packing.select_one('.sf-tp-product-body').get_text()
|
||||
faq = soup.select_one('#sec-faq')
|
||||
assert faq is not None and faq.select_one('h2.sf-tp-panel-title').get_text(strip=True) == 'FAQ'
|
||||
assert 'Lead time is 20 days' in faq.get_text()
|
||||
# 正文区块:overview 只剩拆出后的正文(区块内容不重复渲染)
|
||||
overview = soup.select_one('#overview')
|
||||
assert 'HP-200 body copy' in overview.get_text()
|
||||
assert 'Export grade packing' not in overview.get_text() and 'Lead time is 20 days' not in overview.get_text()
|
||||
# 静态契约:区块全部平铺(无面板隐藏类/内联样式),panel-title 常显
|
||||
assert 'sf-tp-tabs-enabled' not in html
|
||||
sections_all = soup.select('.sf-tp-product-tabbed .sf-tp-detail-section')
|
||||
assert len(sections_all) == 5 # overview + 2 自定义区块 + specifications + features
|
||||
assert all(s.get('style') is None for s in sections_all)
|
||||
assert soup.select_one('#overview .sf-tp-panel-title') is not None
|
||||
assert soup.select_one('#specifications .sf-tp-panel-title') is not None
|
||||
# 切换脚本在场(cms 尾部脚本移植:容器加 sf-tp-tabs-enabled + 面板 active 切换)
|
||||
assert "classList.add('sf-tp-tabs-enabled')" in html
|
||||
# 正文面板:rich-body 容器(WP 规格表就在正文里——不拆表,整体渲染)
|
||||
assert soup.select_one('#overview .sf-tp-product-body') is not None
|
||||
# 锚点脚本在场(Task 15 重做:平滑滚动 + scrollspy 高亮)
|
||||
assert 'scrollIntoView' in html and "addEventListener('scroll'" in html
|
||||
# 参数表:cms spec-table 每行两对键值(2 条 specs → 1 行 2 th + 2 td);
|
||||
# fields 键值渲染(下划线转空格 + 首字母大写),品牌等管理字段不进表
|
||||
# fields 键值渲染(下划线转空格 + 首字母大写),品牌/结构化 JSON 字段不进表
|
||||
spec_table = soup.select_one('table.sf-tp-spec-table')
|
||||
assert spec_table is not None
|
||||
rows = spec_table.select('tbody tr')
|
||||
@@ -249,21 +275,50 @@ def test_trade_pro_product_layout_structure(factory):
|
||||
spec_text = spec_table.get_text()
|
||||
assert 'Pressure' in spec_text and '210 bar' in spec_text and '500 km' in spec_text
|
||||
assert 'Quanta' not in spec_text
|
||||
assert 'Sections' not in spec_text and 'Features' not in spec_text and 'Gallery' not in spec_text
|
||||
# Features 区块:fields.features 数据驱动(cms detail-feature-row 同构)
|
||||
feats = soup.select('#features .sf-tp-detail-feature')
|
||||
assert len(feats) == 2
|
||||
assert feats[0].select_one('h3').get_text(strip=True) == 'High pressure'
|
||||
assert '210 bar continuous rating.' in feats[0].get_text()
|
||||
# 相关产品:复用 sf-tp-card-grid;自身不出现在相关列表
|
||||
cards = soup.select('.sf-tp-card-grid .sf-tp-card')
|
||||
assert cards and any('Companion Valve' in c.get_text() for c in cards)
|
||||
assert all('Hydraulic Pump' not in c.get_text() for c in cards)
|
||||
# 询盘板块存在(左联系信息 + 右 CTA/表单位)
|
||||
assert soup.select_one('.sf-tp-product-quote') is not None
|
||||
# CSS 契约(pro.css 12a 块):sticky 选项卡 / 无 JS 平铺回退 / 品牌色下划短线 /
|
||||
# rich-body 表格覆盖内联宽高 / spec-table 移动端横滚
|
||||
# ── 旧同步产物兜底:body 尾部 H2 段识别拆出为锚点区块 ──
|
||||
r2 = factory.get(f'/preview/{sid}/{bid}/product/dn200/')
|
||||
assert r2.status_code == 200, r2.text[:400]
|
||||
soup2 = BeautifulSoup(r2.text, 'html.parser')
|
||||
tabs2 = soup2.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
|
||||
assert [t.get('href') for t in tabs2] == ['#overview', '#sec-packing-delivery', '#sec-our-exhibition']
|
||||
assert 'Packing & Delivery' in tabs2[1].get_text() and 'Our Exhibition' in tabs2[2].get_text()
|
||||
assert 'DN200 intro paragraph' in soup2.select_one('#overview').get_text()
|
||||
assert 'Bundled in 6 m lengths.' not in soup2.select_one('#overview').get_text()
|
||||
assert 'Bundled in 6 m lengths.' in soup2.select_one('#sec-packing-delivery').get_text()
|
||||
assert 'Canton Fair' in soup2.select_one('#sec-our-exhibition').get_text()
|
||||
assert soup2.select_one('#specifications') is None # 无 specs 数据不显 Tab
|
||||
assert soup2.select_one('#features') is None # 无 features 数据不显 Tab
|
||||
# ── 无自定义区块的产品:只有 Product Details + Specifications,无 sec-* Tab ──
|
||||
r3 = factory.get(f'/preview/{sid}/{bid}/product/valve/')
|
||||
assert r3.status_code == 200, r3.text[:400]
|
||||
soup3 = BeautifulSoup(r3.text, 'html.parser')
|
||||
tabs3 = soup3.select('.sf-tp-product-tabs .sf-tp-tabs-row .sf-tp-tab')
|
||||
assert [t.get_text() for t in tabs3] == ['Product Details', 'Specifications']
|
||||
assert all(not (t.get('href') or '').startswith('#sec-') for t in tabs3)
|
||||
# CSS 契约(pro.css 15 块):sticky 锚点选项卡 / scroll-margin 让位 /
|
||||
# 品牌色下划短线 / detail-features 栅格 / rich-body 表格覆盖内联宽高 /
|
||||
# spec-table 移动端横滚
|
||||
pro_css = (ROOT / 'themes' / 'trade-pro' / 'pro.css').read_text(encoding='utf-8')
|
||||
for needle in (
|
||||
'.sf-tp-product-tabs{',
|
||||
'sf-tp-tabs-enabled .sf-tp-panel{display:none}',
|
||||
'sf-tp-tabs-enabled .sf-tp-panel.active{display:block}',
|
||||
'sf-tp-tabs-enabled .sf-tp-panel-title{display:none}',
|
||||
'.sf-tp-tab.active',
|
||||
'text-decoration:none',
|
||||
'scroll-margin-top',
|
||||
'.sf-tp-detail-section > .wrap > h2{',
|
||||
'.sf-tp-detail-features{display:grid',
|
||||
'.sf-tp-detail-feature{display:grid',
|
||||
'width:100% !important',
|
||||
'height:auto !important',
|
||||
'.sf-tp-spec-table{min-width:720px}',
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
{# trade-pro product detail — cms 产品详情精确对齐重做(Task 12a,重做 10b)。
|
||||
{# trade-pro product detail — cms 产品详情精确对齐重做(Task 12a,重做 10b);
|
||||
Task 15 选项卡改锚点 Tab + 每产品自定义区块。
|
||||
设计源(唯一权威依据)D:/project/cms/runtimes/astro-site-runtime/src/pages/
|
||||
[locale]/products/[slug].astro,区块序列逐块对照:
|
||||
detail-hero(面包屑 + 1.08/0.92 双栏:画廊/标题区,cms 产品名 h2 全页无 h1)
|
||||
→ product-tabbed(sticky 选项卡:Product Details 正文恒有 + Specifications
|
||||
参数表有数据才显)→ related-detail(sf-tp-card 卡片网格)→ quote-section
|
||||
→ product-tabbed(sticky 锚点选项卡:Product Details 正文恒有 → 每产品自定义
|
||||
区块 detail_sections(wppop_sections 迁移,标题来自数据)→ Specifications /
|
||||
Features 按数据显隐)→ related-detail(sf-tp-card 卡片网格)→ quote-section
|
||||
(左联系信息 + 右 CTA/外部表单)。
|
||||
与 cms 的三处结构决策:
|
||||
1. 选项卡系统照搬 cms:容器 data-sf-tp-tabbed + .sf-tp-panel/.active 面板 +
|
||||
尾部内联 <script> 切换(运行时给容器加 sf-tp-tabs-enabled,CSS 只显示
|
||||
active 面板并隐藏 panel-title)——无 JS 时默认形态即 cms 回退形态:面板
|
||||
全部平铺可读、panel-title 可见,静态可抓取。
|
||||
1. 选项卡为锚点跳转非面板切换(用户要求,Task 15):Tab 是 #anchor 链接,
|
||||
区块全部平铺渲染 + panel-title 常显——无 JS 时原生锚点跳转、全部区块可读,
|
||||
静态可抓取;JS 渐进增强平滑滚动 + scrollspy 高亮当前 Tab 并在横滚行内居中。
|
||||
2. 正文不拆表:WordPress 规格表 <table> 直接在 body HTML 里,正文整体作为
|
||||
Product Details 面板渲染(.sf-tp-product-body rich-body 表格样式:全宽/
|
||||
Product Details 区块渲染(.sf-tp-product-body rich-body 表格样式:全宽/
|
||||
发丝框/斑马纹/覆盖内联宽高/单元格 div 净空);specs 变量有数据时另设
|
||||
Specifications 面板(干净 spec-table,每行两对键值,从 fields 读)。
|
||||
Specifications 区块(干净 spec-table,每行两对键值,从 fields 读)。
|
||||
自定义区块数据由 theme_pro.product_detail_sections 供给:fields.sections
|
||||
结构化优先,旧同步产物的 body H2 段识别拆出(正文 overview 不重复渲染)。
|
||||
3. 画廊交互复用平台 pro.js 既有钩子(.pro-product-media /
|
||||
.pro-gallery-thumbs / [data-gallery-image]):缩略图点击换主图 + 主图灯箱,
|
||||
零新增 JS;无 JS 时全部图片仍平铺可读。无图回退封面,再无则占位渐变
|
||||
@@ -61,27 +64,47 @@ Specifications 面板(干净 spec-table,每行两对键值,从 fields 读
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{# 选项卡详情(cms .product-tabbed):Tab 列表按数据动态生成——
|
||||
Product Details(正文)恒有;Specifications 仅 specs 有数据才显示。
|
||||
cms 的每产品自定义区块/Features 无平台数据源,不进 Tab。 #}
|
||||
{# 选项卡详情(cms .product-tabbed,Task 15 改锚点 Tab):Tab 序列完全数据
|
||||
驱动,对齐 cms detailTabs——Product Details(正文)恒有 → 每产品自定义区块
|
||||
detail_sections(CMS wppop_sections 迁移:Packing & Delivery / Our Exhibition /
|
||||
Our Certifications / Our Company / FAQ,标题来自数据不固定;theme_pro 从
|
||||
fields.sections 读、旧同步产物从 body H2 段识别拆出)→ Specifications(specs
|
||||
有数据才显)→ Features(fields.features 有数据才显)。
|
||||
Tab 是锚点跳转非面板切换(用户要求 + 区块全部平铺渲染):无 JS 时原生锚点
|
||||
跳转 + 全部区块可读、标题可见,静态可抓取;JS 平滑滚动 + scrollspy 高亮。 #}
|
||||
{% set tp_spec_rows = specs | rejectattr('label', 'equalto', 'gallery') | list %}
|
||||
<div class="sf-tp-product-tabbed" data-sf-tp-tabbed>
|
||||
<nav class="sf-tp-product-tabs" aria-label="Product details">
|
||||
<div class="wrap sf-tp-tabs-row">
|
||||
<button class="sf-tp-tab active" data-panel="overview" role="tab" aria-selected="true" type="button">Product Details</button>
|
||||
<a class="sf-tp-tab active" href="#overview" role="tab" aria-selected="true">Product Details</a>
|
||||
{% for s in detail_sections %}
|
||||
<a class="sf-tp-tab" href="#{{ s.id }}" role="tab" aria-selected="false">{{ s.title }}</a>
|
||||
{% endfor %}
|
||||
{% if tp_spec_rows %}
|
||||
<button class="sf-tp-tab" data-panel="specifications" role="tab" aria-selected="false" type="button">Specifications</button>
|
||||
<a class="sf-tp-tab" href="#specifications" role="tab" aria-selected="false">Specifications</a>
|
||||
{% endif %}
|
||||
{% if detail_features %}
|
||||
<a class="sf-tp-tab" href="#features" role="tab" aria-selected="false">Features</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<section id="overview" class="sf-tp-detail-section sf-tp-panel active">
|
||||
|
||||
<section id="overview" class="sf-tp-detail-section">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">Product details</h2>
|
||||
<div class="sf-tp-product-body">{{ body_copy(content, page) }}</div>
|
||||
<div class="sf-tp-product-body">{{ body_copy(detail_overview, page) }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% for s in detail_sections %}
|
||||
<section id="{{ s.id }}" class="sf-tp-detail-section">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">{{ s.title }}</h2>
|
||||
<div class="rich-body sf-tp-product-body">{{ s.content }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% if tp_spec_rows %}
|
||||
<section id="specifications" class="sf-tp-detail-section sf-tp-detail-section--compact sf-tp-panel">
|
||||
<section id="specifications" class="sf-tp-detail-section sf-tp-detail-section--compact">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">Specifications</h2>
|
||||
<div class="sf-tp-table-scroll">
|
||||
@@ -97,29 +120,64 @@ cms 的每产品自定义区块/Features 无平台数据源,不进 Tab。 #}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% if detail_features %}
|
||||
<section id="features" class="sf-tp-detail-section sf-tp-detail-section--compact">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">Features</h2>
|
||||
<div class="sf-tp-detail-features">
|
||||
{% for f in detail_features %}
|
||||
<div class="sf-tp-detail-feature">
|
||||
<span aria-hidden="true">✦</span>
|
||||
<div><h3>{{ f.title }}</h3><p>{{ f.desc }}</p></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
/* 详情选项卡(cms [slug].astro 尾部脚本移植):渐进增强——运行时给容器加
|
||||
sf-tp-tabs-enabled(CSS 只显示 active 面板并隐藏 panel-title);无 JS 时
|
||||
面板全部平铺可读。 */
|
||||
/* 锚点选项卡(Task 15,cms [slug].astro 尾部脚本重做):点击平滑滚动到区块
|
||||
并在横滚行内居中;scrollspy 滚动高亮当前 Tab。无 JS 时为原生锚点跳转,
|
||||
区块全部平铺可读。 */
|
||||
(function () {
|
||||
var tabbed = document.querySelector('[data-sf-tp-tabbed]');
|
||||
if (!tabbed) return;
|
||||
tabbed.classList.add('sf-tp-tabs-enabled');
|
||||
var nav = tabbed.querySelector('.sf-tp-product-tabs');
|
||||
var tabs = Array.prototype.slice.call(tabbed.querySelectorAll('.sf-tp-tab'));
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
tabs.forEach(function (t) {
|
||||
var on = t === tab;
|
||||
var targets = tabs.map(function (t) {
|
||||
return document.getElementById((t.getAttribute('href') || '').slice(1));
|
||||
});
|
||||
function activate(i, center) {
|
||||
tabs.forEach(function (t, j) {
|
||||
var on = i === j;
|
||||
t.classList.toggle('active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
Array.prototype.slice.call(tabbed.querySelectorAll('.sf-tp-panel')).forEach(function (p) {
|
||||
p.classList.toggle('active', p.id === tab.getAttribute('data-panel'));
|
||||
});
|
||||
tab.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
if (center && tabs[i] && tabs[i].scrollIntoView) {
|
||||
tabs[i].scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
tabs.forEach(function (tab, i) {
|
||||
tab.addEventListener('click', function (e) {
|
||||
var target = targets[i];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
activate(i, true);
|
||||
});
|
||||
});
|
||||
var current = -1;
|
||||
function spy() {
|
||||
var mark = (nav ? nav.getBoundingClientRect().bottom : 0) + 32;
|
||||
var idx = 0;
|
||||
targets.forEach(function (el, i) {
|
||||
if (el && el.getBoundingClientRect().top <= mark) idx = i;
|
||||
});
|
||||
if (idx !== current) { current = idx; activate(idx, true); }
|
||||
}
|
||||
window.addEventListener('scroll', spy, { passive: true });
|
||||
spy();
|
||||
})();
|
||||
</script>
|
||||
{% set tp_related = (groups['product'] + groups['vehicle']) | rejectattr('route', 'equalto', page.route) | list %}
|
||||
|
||||
@@ -104,8 +104,9 @@
|
||||
.sf-theme-trade-pro .sf-tp-cta h2{margin-block-end:var(--sf-tp-space-2);color:var(--sf-tp-color-text-inverse)}
|
||||
.sf-theme-trade-pro .sf-tp-cta p{color:color-mix(in srgb,var(--sf-tp-color-text-inverse) 83%,transparent);margin:0}
|
||||
|
||||
/* ── 产品详情(Task 12a,[slug].astro 精确对齐重做 10b):hero 画廊/标题 +
|
||||
sticky 选项卡(无 JS 平铺回退)+ rich-body 正文表格 + spec-table + 询盘。
|
||||
/* ── 产品详情(Task 12a,[slug].astro 精确对齐重做 10b;Task 15 选项卡改
|
||||
锚点 Tab + 自定义区块/特性栅格):hero 画廊/标题 + sticky 锚点选项卡 +
|
||||
rich-body 正文表格 + spec-table + detail-features + 询盘。
|
||||
断点映射:cms 900 与 960(询盘堆叠)→ 900;cms 620 → 620。 ── */
|
||||
.sf-theme-trade-pro .sf-tp-product-hero{padding-block:calc(var(--sf-tp-space-8) + var(--sf-tp-space-4)) var(--sf-tp-space-8)}
|
||||
.sf-theme-trade-pro .sf-tp-breadcrumb{display:flex;flex-wrap:wrap;align-items:center;color:var(--sf-tp-color-text-muted);font-size:var(--sf-tp-font-size-sm);margin-block-end:var(--sf-tp-space-6)}
|
||||
@@ -137,13 +138,13 @@
|
||||
/* Tab 单行横滚(数量多不换行);隐藏滚动条 + scroll-snap 贴合(cms .tabs-row) */
|
||||
.sf-theme-trade-pro .sf-tp-tabs-row{display:flex;gap:var(--sf-tp-space-6);overflow-x:auto;scroll-behavior:smooth;scrollbar-width:none;scroll-snap-type:x proximity}
|
||||
.sf-theme-trade-pro .sf-tp-tabs-row::-webkit-scrollbar{display:none}
|
||||
.sf-theme-trade-pro .sf-tp-tab{flex:0 0 auto;white-space:nowrap;padding:var(--sf-tp-space-4) var(--sf-tp-space-1);color:var(--sf-tp-color-text-secondary);font-weight:var(--sf-tp-font-weight-semibold);font-size:var(--sf-tp-font-size-sm);background:none;border:none;border-block-end:var(--sf-tp-border-width-strong) solid transparent;cursor:pointer;scroll-snap-align:center}
|
||||
.sf-theme-trade-pro .sf-tp-tab{flex:0 0 auto;white-space:nowrap;padding:var(--sf-tp-space-4) var(--sf-tp-space-1);color:var(--sf-tp-color-text-secondary);font-weight:var(--sf-tp-font-weight-semibold);font-size:var(--sf-tp-font-size-sm);background:none;border:none;border-block-end:var(--sf-tp-border-width-strong) solid transparent;cursor:pointer;scroll-snap-align:center;text-decoration:none}
|
||||
.sf-theme-trade-pro .sf-tp-tab.active,.sf-theme-trade-pro .sf-tp-tab:hover{color:var(--sf-tp-color-text-link);border-block-end-color:var(--sf-tp-color-border-brand)}
|
||||
/* 面板切换(cms [data-tabbed].tabs-enabled):JS 给容器加 sf-tp-tabs-enabled 后
|
||||
仅显示激活面板并隐藏 panel-title;无 JS 时面板全部平铺可读、标题可见 */
|
||||
.sf-theme-trade-pro .sf-tp-product-tabbed.sf-tp-tabs-enabled .sf-tp-panel{display:none}
|
||||
.sf-theme-trade-pro .sf-tp-product-tabbed.sf-tp-tabs-enabled .sf-tp-panel.active{display:block}
|
||||
.sf-theme-trade-pro .sf-tp-product-tabbed.sf-tp-tabs-enabled .sf-tp-panel-title{display:none}
|
||||
.sf-theme-trade-pro .sf-tp-tab:focus-visible{outline:2px solid var(--sf-tp-color-border-brand);outline-offset:-2px}
|
||||
/* 锚点选项卡(Task 15 重做 12a 面板切换):Tab 是页内锚点链接,区块全部平铺
|
||||
渲染、panel-title 常显(无 JS 原生跳转也可读);JS 平滑滚动 + scrollspy
|
||||
高亮当前 Tab(.sf-tp-tab.active)。区块锚点滚动让位 sticky 条见下方
|
||||
.sf-tp-detail-section 的 scroll-margin-top。 */
|
||||
/* 详情面板(cms .detail-section):区块距 + 锚点滚动让位 sticky 选项卡;
|
||||
区块标题品牌色 3px 下划短线(cms .detail-section > .wrap > h2) */
|
||||
.sf-theme-trade-pro .sf-tp-detail-section{padding-block:calc(var(--sf-tp-space-10) + var(--sf-tp-space-1));scroll-margin-top:calc(var(--sf-tp-space-16) + var(--sf-tp-space-16))}
|
||||
@@ -165,6 +166,13 @@
|
||||
.sf-theme-trade-pro .sf-tp-spec-table{width:100%;border-collapse:collapse;font-size:var(--sf-tp-font-size-sm)}
|
||||
.sf-theme-trade-pro .sf-tp-spec-table th,.sf-theme-trade-pro .sf-tp-spec-table td{border:var(--sf-tp-border-width-hairline) solid var(--sf-tp-color-border-default);padding:calc(var(--sf-tp-space-4) - var(--sf-tp-space-1)) var(--sf-tp-space-4);text-align:start}
|
||||
.sf-theme-trade-pro .sf-tp-spec-table th{background:var(--sf-tp-color-surface-muted);font-weight:var(--sf-tp-font-weight-semibold)}
|
||||
/* 特性栅格(cms .detail-feature-row,fields.features 数据驱动):品牌色星标 +
|
||||
标题/描述两行卡;auto-fit 自适应条目数(cms 固定 4 列,数据条目不定) */
|
||||
.sf-theme-trade-pro .sf-tp-detail-features{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--sf-tp-space-3) var(--sf-tp-space-6)}
|
||||
.sf-theme-trade-pro .sf-tp-detail-feature{display:grid;grid-template-columns:auto 1fr;gap:var(--sf-tp-space-3);align-items:start;padding-block:var(--sf-tp-space-2)}
|
||||
.sf-theme-trade-pro .sf-tp-detail-feature>span{color:var(--sf-tp-color-text-brand);font-size:var(--sf-tp-font-size-xl);line-height:var(--sf-tp-line-height-tight)}
|
||||
.sf-theme-trade-pro .sf-tp-detail-feature h3{font-size:var(--sf-tp-font-size-md);margin-block-end:var(--sf-tp-space-1)}
|
||||
.sf-theme-trade-pro .sf-tp-detail-feature p{margin:0;color:var(--sf-tp-color-text-muted);font-size:var(--sf-tp-font-size-sm)}
|
||||
/* 询盘板块(cms quote-grid 同构):左联系信息 + 右表单卡 */
|
||||
.sf-theme-trade-pro .sf-tp-quote-grid{display:grid;grid-template-columns:0.85fr 1.15fr;gap:calc(var(--sf-tp-space-12) + var(--sf-tp-space-4));align-items:start}
|
||||
.sf-theme-trade-pro .sf-tp-quote-info>p{color:var(--sf-tp-color-text-muted)}
|
||||
|
||||
Reference in New Issue
Block a user