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,
+15 -10
View File
@@ -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]:
+90 -35
View File
@@ -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}',
+87 -29
View File
@@ -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 %}
+16 -8
View File
@@ -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)}