feat: refine trade-pro variant pages (brand/service/faq/search/notfound)

Task 11 (layout port finale): rebuild the five thin variant pages in the
sf-tp design language, informed by upstream industrial/automotive
1.3.0/1.4.0 information architecture.

- brand: page-hero (breadcrumb Home > Brands > name, cover bg, anchor
  button) + related-product card grid (fields.brand == page title) +
  brand profile body only when authored; derived placeholder summary
  excluded, empty brands get an empty state, never fabricated intros.
- service: page-hero + 1.2/0.8 detail grid (article-body typography +
  sticky CTA card with contact/back links); empty body collapses to a
  centered solo column with the CTA card kept.
- faq: page-hero + article-body for the rich-text body (platform has no
  faq content type; sanitize strips details/summary so the typography
  layer carries it, block-driven details coexist via sf-canvas) +
  dark contact-guidance card; empty body swaps to an empty state.
- search: adopts the same platform mechanism as all four upstream
  themes (site-runtime.js ?q= client search over dist-root
  search-index.json); #sf-search/#sf-search-results/#sf-search-count
  hooks and .sf-search-page kept as JS contract, panel and injected
  result cards restyled via scoped cms-token overrides.
- notfound: branded dark-band 404 (icon macro, giant 404 h1, English
  copy, back-home + browse-products buttons); no end-cta.

CSS: Task 11 block appended to pro.css (new sf-tp-* classes only, own
900/620 media queries; shared hero/grid/typography responsive rules
come from existing 10a/10c/10d blocks; search overrides beat
factory.css by specificity without touching hooks).

Tests: test_trade_pro_variant_pages_structure covers brand fixture
(related filtering, empty forms), service/faq body-vs-empty, search
mechanism contract + noindex + index asset, 404 structure, v3 spot
checks (single h1, chrome, SVG arrows) and clean build report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-26 16:32:15 +08:00
parent e21dbb6518
commit 391d22f1a8
7 changed files with 499 additions and 8 deletions
+176
View File
@@ -798,6 +798,182 @@ def test_trade_pro_standard_layout_structure(factory):
assert ssoup.select_one('.sf-tp-page-hero-lead') is not None # bootstrap 摘要在导语位
def test_trade_pro_variant_pages_structure(factory):
# Task 11:brand/service/faq/search/notfound 五个次要页面精修(布局移植
# 收官)。设计参照:上游四主题 1.3.0/1.4.0 对应页面的信息架构
# (automotive 1.4.0 brand 页最完整:横幅 + 面包屑 + 锚点按钮 + 车型
# 网格 + 品牌故事;其余为通用信息页骨架)+ 已建立的 sf-tp 设计语言
# (page-hero/面包屑/卡片网格/排版层/空态)。
# 数据事实(make_context):brand kind 由 related 提供同品牌产品
# (fields.brand == 页题);service/faq 仅页面条目上下文(正文富文本,
# sanitize 剥离 details/summary——theme.css 的折叠样式只服务块系统注入
# 的 custom_blocks,模板正文只能走排版层);search 为构建器生成的
# noindex 静态页(site-runtime.js + search-index.json 的 ?q= 客户端检索,
# 上游四主题同款机制,#sf-search/#sf-search-results/#sf-search-count
# 三钩子是平台 JS 契约);notfound 走 mode='notfound'(noindex meta)。
import re
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
# ── 数据夹具:显式品牌(正文/封面)+ 2 同品牌产品 + 1 异品牌产品 +
# 服务条目(正文/封面)+ FAQ 页(page_template 显式指定)──
made = factory.post(f'/api/sites/{sid}/entries', json={
'type': 'brand', 'title': 'Quanta', 'route': '/brands/quanta/',
'cover': 'https://imgs.example/quanta-hall.jpg',
'summary': 'German engineering brand for flow control systems.',
'body': '<p>Quanta manufactures flow control systems for export markets.</p><h2>Heritage</h2><p>Founded as an industrial valve maker.</p>'})
assert made.status_code in (200, 201), made.text
batch = [
{'type': 'product', 'title': 'Quanta Valve V1', 'route': '/product/quanta-valve-v1/',
'summary': 'Control valve from Quanta.', 'meta': {'fields': {'brand': 'Quanta', 'category': 'Valves'}}},
{'type': 'product', 'title': 'Quanta Pump P2', 'route': '/product/quanta-pump-p2/',
'summary': 'Centrifugal pump from Quanta.', 'meta': {'fields': {'brand': 'Quanta'}}},
{'type': 'product', 'title': 'Rival Actuator A9', 'route': '/product/rival-actuator/',
'summary': 'Actuator from another brand.', 'meta': {'fields': {'brand': 'Rival'}}},
{'type': 'service', 'title': 'Export Documentation Service', 'route': '/services/export-docs/',
'cover': 'https://imgs.example/docs-desk.jpg',
'summary': 'Certificates of origin, packing lists and customs paperwork.',
'body': '<p>We prepare the complete document set for every shipment.</p><h2>What is included</h2><p>Commercial invoice, packing list and certificate of origin.</p>'},
{'type': 'page', 'title': 'Shipping FAQ', 'route': '/faq-shipping/',
'summary': 'Frequently asked questions about shipping and documents.',
'body': '<p>Answers about lead times and paperwork.</p><h2>How long is the lead time?</h2><p>Standard orders ship in 15 working days.</p>',
'meta': {'page_template': 'faq'}},
]
for row in batch:
r = factory.post(f'/api/sites/{sid}/entries', json=row)
assert r.status_code in (200, 201), r.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']
# ── v3 契约抽验:五种页面 200 / 唯一 h1 / pro-header + pro-footer ──
variant_urls = ['/brands/quanta/', '/services/export-docs/', '/faq-shipping/', '/search/', '/404.html']
pages = {}
for url in variant_urls:
r = factory.get(f'/preview/{sid}/{bid}{url}')
assert r.status_code == 200, (url, r.text[:200])
soup = BeautifulSoup(r.text, 'html.parser')
assert len(soup.find_all('h1')) == 1, url
assert soup.select_one('.pro-header') and soup.select_one('.pro-footer'), url
# 交互箭头一律 SVG(Task 9 契约)
assert not re.search(r'[←→↗↘⌕▾]', ''.join(a.get_text() for a in soup.find_all('a'))), url
pages[url] = soup
# ── brand:hero(面包屑 Home › Brands › 品名 + 封面底图 + 锚点按钮)+
# 同品牌产品网格(related,异品牌不混入)+ 品牌介绍(正文存在才渲染)──
bsoup = pages['/brands/quanta/']
hero = bsoup.select_one('section.sf-tp-page-hero.sf-tp-brand-hero')
assert hero is not None
bg = hero.select_one('.sf-tp-page-hero-bg')
assert bg is not None and 'quanta-hall.jpg' in bg.get('src', '')
assert hero.select_one('.sf-tp-breadcrumb a[href$="/"]')
assert hero.select_one('.sf-tp-breadcrumb a[href$="/brands/"]')
assert hero.select_one('.sf-tp-breadcrumb-current').get_text(strip=True) == 'Quanta'
assert hero.select_one('.sf-tp-page-hero-copy h1').get_text(strip=True) == 'Quanta'
# hero 锚点按钮(automotive 1.4.0 同款信息架构):有产品才渲染
assert hero.select_one('.sf-tp-page-hero-copy a.sf-tp-btn[href="#tp-brand-products"]') is not None
# 产品网格:related(fields.brand == 页题),异品牌产品不出现在页面上
# (groups 无稳定顺序——断言与插入顺序解耦,只验集合内容)
grid = bsoup.select_one('.sf-tp-brand-products')
assert grid is not None and grid.get('id') == 'tp-brand-products'
cards = grid.select('.sf-tp-card-grid .sf-tp-card')
assert len(cards) == 2
card_titles = {c.select_one('h3').get_text(strip=True) for c in cards}
assert card_titles == {'Quanta Valve V1', 'Quanta Pump P2'}
assert 'Rival Actuator' not in grid.get_text()
assert cards[0].select_one('.sf-tp-card-media') and cards[0].select_one('a.sf-tp-more svg')
assert grid.select_one('.sf-tp-section-head a.sf-tp-more[href$="/products/"] svg')
# 品牌介绍:body 存在才渲染(排版层双挂);空品牌不虚构介绍
profile = bsoup.select_one('.sf-tp-brand-profile .sf-tp-article-body .pro-rich')
assert profile is not None and 'Heritage' in profile.get_text()
# ── service:hero(面包屑 Home › Services › 标题)+ 正文排版层 + CTA 侧卡 ──
ssoup = pages['/services/export-docs/']
shero = ssoup.select_one('section.sf-tp-page-hero.sf-tp-service-hero')
assert shero is not None
sbg = shero.select_one('.sf-tp-page-hero-bg')
assert sbg is not None and 'docs-desk.jpg' in sbg.get('src', '')
assert shero.select_one('.sf-tp-breadcrumb a[href$="/services/"]')
assert shero.select_one('.sf-tp-breadcrumb-current').get_text(strip=True) == 'Export Documentation Service'
body = ssoup.select_one('.sf-tp-service-body.sf-tp-article-body .pro-rich')
assert body is not None and 'What is included' in body.get_text()
aside = ssoup.select_one('.sf-tp-service-aside')
assert aside is not None
assert aside.select_one('a.sf-tp-btn[href$="/contact/"]') is not None # CTA
assert aside.select_one('a[href$="/services/"]') is not None # 返回服务列表
# ── faq:hero + 正文排版层(平台无 faq 内容类型,正文富文本承担)+
# 引导联系卡 ──
fsoup = pages['/faq-shipping/']
fhero = fsoup.select_one('section.sf-tp-page-hero.sf-tp-faq-hero')
assert fhero is not None
assert fhero.select_one('.sf-tp-breadcrumb a[href$="/faq/"]')
fbody = fsoup.select_one('.sf-tp-faq-body.sf-tp-article-body .pro-rich')
assert fbody is not None and 'How long is the lead time?' in fbody.get_text()
guide = fsoup.select_one('.sf-tp-faq-guide')
assert guide is not None and guide.select_one('a.sf-tp-btn[href$="/contact/"]')
# ── search:与上游四主题同款平台机制(site-runtime.js ?q= 客户端检索
# search-index.json),三钩子契约保留 + noindex + sf-tp 化的搜索面板 ──
psoup = pages['/search/']
assert 'noindex' in str(psoup.find('meta', attrs={'name': 'robots'}))
assert psoup.select_one('main#main-content.sf-search-page')
assert psoup.select_one('label[for="sf-search"]')
inp = psoup.select_one('input#sf-search[type="search"]')
assert inp is not None and inp.get('autofocus') is not None
assert psoup.select_one('#sf-search-results') is not None
assert psoup.select_one('#sf-search-count[aria-live="polite"]') is not None
assert psoup.find('h1').get_text(strip=True) == 'Site Search'
# 机制资源随构建分发:site-runtime.js 页面引用 + 检索索引含真实内容
# (write_assets 把 search-index.json 写在 dist 根,site-runtime.js 以
# <base href="/"> 相对解析——检索 URL 契约)
sr = factory.get(f'/preview/{sid}/{bid}/search/')
assert 'site-runtime.js' in sr.text
idx = factory.get(f'/preview/{sid}/{bid}/search-index.json')
assert idx.status_code == 200 and 'Quanta Valve V1' in idx.text
# search/notfound 无结尾 CTA 带(pro.html 既有分支)
assert 'pro-end-cta' not in sr.text
# ── notfound:品牌化 404(大标题 + 英文文案 + 双按钮 + SVG 图标)──
nsoup = pages['/404.html']
assert 'noindex' in str(nsoup.find('meta', attrs={'name': 'robots'}))
nsec = nsoup.select_one('.sf-tp-notfound')
assert nsec is not None
assert nsec.find('h1').get_text(strip=True) == '404'
assert nsec.select_one('.icon-circle svg') is not None # icon 宏渲染
assert nsec.select_one('a.sf-tp-btn[href$="/"]') is not None # Back to home
assert nsec.select_one('a[href$="/products/"]') is not None # Browse products
assert 'pro-end-cta' not in str(nsoup)
# 本构建链接全通(新增面包屑/CTA/锚点不产生断链)
report = factory.get(f'/api/sites/{sid}/builds/{bid}/report').json()
assert not report['errors'] and not report['broken_links'] and not report['missing_local_images']
# ── 空数据形态:空品牌(无产品/无正文)只显空态不虚构介绍;空服务/
# 空 FAQ 正文整段隐藏、CTA/引导保留 ──
sid2 = site(factory)
for row in [
{'type': 'brand', 'title': 'Orphan', 'route': '/brands/orphan/'},
{'type': 'service', 'title': 'Placeholder Service', 'route': '/services/placeholder/'},
{'type': 'page', 'title': 'Bare FAQ', 'route': '/faq-bare/', 'meta': {'page_template': 'faq'}},
]:
r = factory.post(f'/api/sites/{sid2}/entries', json=row)
assert r.status_code in (200, 201), r.text
assert factory.put(f'/api/factory/sites/{sid2}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
bid2 = factory.post(f'/api/sites/{sid2}/builds', json={}).json()['version']
for url, absent, present in [
('/brands/orphan/', ['.sf-tp-brand-products', '.sf-tp-brand-profile', '.sf-tp-card'], ['.sf-tp-listing-empty']),
('/services/placeholder/', ['.sf-tp-service-body'], ['.sf-tp-service-aside']),
('/faq-bare/', ['.sf-tp-faq-body'], ['.sf-tp-faq-guide']),
]:
r = factory.get(f'/preview/{sid2}/{bid2}{url}')
assert r.status_code == 200, (url, r.text[:200])
soup = BeautifulSoup(r.text, 'html.parser')
assert len(soup.find_all('h1')) == 1, url
for sel in absent:
assert soup.select_one(sel) is None, (url, sel)
for sel in present:
assert soup.select_one(sel) is not None, (url, sel)
def test_trade_pro_chrome_layout(factory):
# Task 10j:cms 页眉/页脚 chrome 视觉还原 + 手机端抽屉导航。
# 设计源 D:/project/cms/runtimes/astro-site-runtime/src/components/