feat: trust_bar + applications_band blocks; homepage now block-driven

- blueprints: register trust_bar (icon/title/text items) and applications_band
  (2x2 dark-band tiles) block types; render with escaped items, ordinal icon
  fallback, 8-item cap; auto-exposed in editor via /api/editor/blocks and
  grouped under business in the studio palette
- blocks.css: platform base layer for both blocks (app/static + trade-pro
  Layer A kept verbatim in sync) + trade-pro bridge vocabulary (icon-circle,
  hairline dividers, navy->brand gradient band, glass tiles, 900/620
  breakpoints, reduced-motion) without touching canvas hooks
- tests: test_trade_pro_home_block_driven covers the full studio-draft
  PUT -> publish -> build chain; 8 block types on the homepage, template
  markers absent under layout_mode=replace, clean build report
- scripts/setup_home_blocks.py: production write path for site 483295c29ea0
  (login -> draft -> publish -> build -> verify); real catalogue counts feed
  the stats block; run after the Docker image picks up the new block types

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-28 18:11:32 +08:00
parent c4d9b1075c
commit 45c39b1695
6 changed files with 405 additions and 1 deletions
+106
View File
@@ -1122,6 +1122,112 @@ def test_trade_pro_blocks_visual_bridge(factory):
assert not report['errors'] and not report['broken_links']
def test_trade_pro_home_block_driven(factory):
# Task 13b:首页从模板驱动切换为区块驱动(layout_mode=replace)。
# 序列对照 home.html 模板设计:hero → trust_bar(新增)→ image_text+stats →
# collection(product,4) → applications_band(新增)→ features →
# collection(post,3) → cta。写入走真实 studio-draft 链路(PUT 草稿 →
# publish 发布 → 构建),与运维脚本 scripts/setup_home_blocks.py 同一条
# 代码路径。home.html 模板文件保留(其他站点仍可用),仅本站首页 replace。
import io
from app import blueprints
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
img = 'https://imgs.example/hesu-plant.jpg'
batch = [{'type': 'product', 'title': f'HDPE Pipe PE100 {i}',
'summary': f'Pressure pipe {i} for municipal water supply.', 'cover': img,
'meta': {'fields': {'brand': 'HESU', 'category': 'Pipes'}}} for i in range(4)]
batch += [{'type': 'post', 'title': f'Export Update {i}',
'summary': f'Dock loading report {i}.'} for i in range(3)]
payload = json.dumps(batch).encode('utf-8')
imported = factory.post(f'/api/sites/{sid}/import',
files={'file': ('home-batch.json', io.BytesIO(payload), 'application/json')})
assert imported.status_code == 200, imported.text[:300]
assert factory.put(f'/api/factory/sites/{sid}/theme', json={'theme': 'trade-pro', 'version': '1.0.0'}).status_code == 200
home = next(e for e in factory.get(f'/api/sites/{sid}/entries').json() if e['route'] == '/')
blocks = [
blueprints.new_block('hero', {'title': 'Reliable piping systems, exported worldwide',
'subtitle': 'GLOBAL SOURCING · MADE RELIABLE',
'description': 'Specifications, certification and packaging published with every listing.',
'image': img, 'button': 'Browse products', 'href': '/products/'}),
blueprints.new_block('trust_bar', {}),
blueprints.new_block('image_text', {'title': 'One partner from inquiry to delivery.',
'subtitle': 'ABOUT US', 'image': img,
'description': 'Product specifications, quality control and export documentation in one place.',
'button': 'About our company', 'href': '/about/'}),
blueprints.new_block('stats', {'items': [{'value': '48', 'label': 'Products'},
{'value': '12', 'label': 'Categories'},
{'value': '30', 'label': 'Markets served'},
{'value': '10', 'label': 'Export years'}]}),
blueprints.new_block('collection', {'title': 'Featured products', 'subtitle': 'TRADE CATALOGUE',
'source': 'product', 'limit': 4, 'columns': 4, 'image_fit': 'cover',
'button': 'Browse all products', 'href': '/products/'}),
blueprints.new_block('applications_band', {}),
blueprints.new_block('features', {'title': 'Why buyers stay with us', 'items': [
{'title': 'Verified sourcing', 'text': 'Suppliers audited against your specifications before the first sample.'},
{'title': 'Strict quality control', 'text': 'Pre-shipment inspection with photographic evidence for every batch.'},
{'title': 'Export documentation', 'text': 'Certification, packing lists and customs papers prepared in-house.'},
{'title': 'Dedicated support', 'text': 'One trade desk contact from inquiry to container loading.'}]}),
blueprints.new_block('collection', {'title': 'Latest updates', 'subtitle': 'COMPANY NEWS',
'source': 'post', 'limit': 3, 'columns': 3, 'image_fit': 'cover',
'button': 'View all news', 'href': '/news/'}),
blueprints.new_block('cta', {'title': 'Send one inquiry, get a matched quote.',
'subtitle': 'Share your specifications and target market; our trade desk replies within one business day.',
'button': 'Request a quote', 'href': '/contact/'}),
]
put = factory.put(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft',
json={'blocks': blocks, 'page_template': 'home', 'layout_mode': 'replace', 'expected_revision': 0})
assert put.status_code == 200, put.text
pub = factory.post(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft/publish', json={'expected_revision': 1})
assert pub.status_code == 200, pub.text
state = factory.get(f'/api/sites/{sid}/pages/{home["id"]}/studio-draft').json()
assert state['state'] == 'published' and state['layout_mode'] == 'replace'
bid = factory.post(f'/api/sites/{sid}/builds', json={}).json()['version']
html = factory.get(f'/preview/{sid}/{bid}/').text
soup = BeautifulSoup(html, 'html.parser')
# ── 8 种区块全部在场(在画布容器内,编辑器结构契约不破坏)──
canvas = soup.select_one('main.sf-canvas.pro-custom-content')
assert canvas is not None
for sel in ('section.sf-hero', 'section.sf-trust_bar', 'section.sf-image_text', 'section.sf-stats',
'section.sf-applications_band', 'section.sf-features', 'section.sf-cta'):
assert canvas.select_one(sel) is not None, sel
collections = canvas.select('section.sf-collection')
assert len(collections) == 2
assert len(collections[0].select('a.sf-item')) == 4 # 产品精选 4 卡
assert len(collections[1].select('a.sf-item')) == 3 # 新闻 3 卡
assert 'HDPE Pipe PE100 0' in collections[0].get_text()
assert 'Export Update' in collections[1].get_text()
# ── 新区块结构契约:信任条(icon-circle+标题+说明+分隔线)与应用带(2×2 瓦片)──
trust = canvas.select('.sf-trust-bar .sf-trust-item')
assert len(trust) == 4
assert all(t.select_one('.sf-trust-icon') and t.find('strong') and t.select_one('.sf-trust-text') for t in trust)
assert 'Certified quality' in trust[0].get_text() and 'Responsive service' in trust[3].get_text()
tiles = canvas.select('.sf-applications-band .sf-app-tile')
assert len(tiles) == 4
assert all(t.select_one('.sf-app-icon') and t.find('h3') and t.find('p') for t in tiles)
assert 'Water supply' in tiles[0].get_text() and 'Mining' in tiles[3].get_text()
# ── 模板不再渲染:home.html 的模板级标记全数缺席(chrome 的 sf-tp-btn 不在此列)──
for marker in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col', 'sf-tp-card-grid',
'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta'):
assert marker not in html, marker
assert len(soup.find_all('h1')) == 1 # 区块 hero 提供唯一 h1,模板缺席不重复
# ── 桥接层语汇:新区块的 trade-pro 视觉规则 + sf-tp 令牌随构建分发 ──
served = factory.get(f'/preview/{sid}/{bid}/assets/blocks.css').text
for rule in ('.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{',
'.sf-theme-trade-pro .sf-trust-item{',
'.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{',
'.sf-theme-trade-pro .sf-app-tile{'):
assert rule in served, rule
assert 'var(--sf-tp-color-surface-inverse)' in served
assert 'var(--sf-tp-color-text-brand)' in served
# 编辑器画布红线(13a 契约延续):新区块样式不触碰画布钩子
assert 'data-block' not in served and 'sf-canvas-selected' not in served
# ── 构建报告零错误 ──
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']
def test_theme_blocks_css_overrides_platform(tmp_path):
from app import builder
dist = tmp_path / 'assets'; dist.mkdir()