From f5dc2f76247134bcfe62b4ff5975ed3a837e5fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 29 Oct 2026 13:49:08 +0800 Subject: [PATCH] Task 15: product detail anchor tabs + per-product custom sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/theme_pro.py | 105 ++++++++++++++++++++++- scripts/sync_cms_data.py | 25 +++--- tests/test_theme_registry.py | 125 ++++++++++++++++++++-------- themes/trade-pro/pages/product.html | 116 +++++++++++++++++++------- themes/trade-pro/pro.css | 24 ++++-- 5 files changed, 311 insertions(+), 84 deletions(-) diff --git a/app/theme_pro.py b/app/theme_pro.py index 261ee17..25afaeb 100644 --- a/app/theme_pro.py +++ b/app/theme_pro.py @@ -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 标题不受影响。── +KNOWN_SECTION_TITLES=frozenset(( + 'packing & delivery','our exhibition','our certifications','our company','faq','features', +)) +_H2_RE=re.compile(r']*>([\s\S]*?)

',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;同名标题追加序号保唯一(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, diff --git a/scripts/sync_cms_data.py b/scripts/sync_cms_data.py index 9adf5e5..6acc93b 100644 --- a/scripts/sync_cms_data.py +++ b/scripts/sync_cms_data.py @@ -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

{sec['title']}

\n{sec['content']}" - if features: - body_html += "\n\n

Features

\n" - for f in features: - body_html += f"\n

{f['title']}

\n

{f['desc']}

\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]: diff --git a/tests/test_theme_registry.py b/tests/test_theme_registry.py index 5a78a71..552696d 100644 --- a/tests/test_theme_registry.py +++ b/tests/test_theme_registry.py @@ -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': '

HP-200 body copy with export-grade tolerances.

', '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': '

Export grade packing on fumigated pallets.

'}, + {'title': 'FAQ', 'content': '

Lead time is 20 days after deposit.

'}]), + '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': '

DN200 intro paragraph stays in overview.

' + '

Packing & Delivery

Bundled in 6 m lengths.

' + '

Our Exhibition

Canton Fair booth photos.

'}) + 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}', diff --git a/themes/trade-pro/pages/product.html b/themes/trade-pro/pages/product.html index eaadca3..4402ae7 100644 --- a/themes/trade-pro/pages/product.html +++ b/themes/trade-pro/pages/product.html @@ -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 面板 + -尾部内联 {% set tp_related = (groups['product'] + groups['vehicle']) | rejectattr('route', 'equalto', page.route) | list %} diff --git a/themes/trade-pro/pro.css b/themes/trade-pro/pro.css index a74ece7..62ce543 100644 --- a/themes/trade-pro/pro.css +++ b/themes/trade-pro/pro.css @@ -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)}