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:
@@ -27,6 +27,8 @@ BLOCKS = {
|
||||
'pricing':{'name':'服务方案','icon':'$','fields':['title','items'],'defaults':{'title':'服务方案','items':[{'name':'标准方案','price':'联系我们','features':['专业咨询','售后服务'],'href':'/contact/'}]}},
|
||||
'map':{'name':'位置导航','icon':'⌖','fields':['title','address','url'],'defaults':{'title':'欢迎来访','address':'请填写实际办公地址','url':''}},
|
||||
'form_link':{'name':'询盘表单入口','icon':'✉','fields':['title','description','button','href'],'defaults':{'title':'提交询盘','description':'欢迎联系我们获取报价和资料','button':'联系销售','href':'/contact/'}},
|
||||
'trust_bar':{'name':'信任条','icon':'✓','fields':['title','items'],'defaults':{'title':'','items':[{'icon':'✓','title':'Certified quality','text':'Inspected before every shipment'},{'icon':'◎','title':'Global export','text':'Delivering to worldwide markets'},{'icon':'▤','title':'Full documentation','text':'Specs and export files on hand'},{'icon':'✉','title':'Responsive service','text':'Replies within one business day'}]}},
|
||||
'applications_band':{'name':'应用展示带','icon':'⊞','fields':['title','subtitle','items'],'defaults':{'title':'Built for real-world projects','subtitle':'APPLICATIONS','items':[{'icon':'◉','title':'Water supply','text':'Municipal piping and treatment networks.'},{'icon':'◍','title':'Drainage','text':'Gravity and pressure sewer systems.'},{'icon':'✦','title':'Gas','text':'PE pipelines for fuel gas distribution.'},{'icon':'◆','title':'Mining','text':'Abrasion-resistant slurry and vent lines.'}]}},
|
||||
}
|
||||
# Property contracts shared with the no-code editor. Unknown executable types
|
||||
# are not supported; all values are still verified by validate_blocks.
|
||||
@@ -308,6 +310,17 @@ def render_blocks(blocks, site, page, entries):
|
||||
visible_items=sum(1 for item in items[:12] if isinstance(item,dict))
|
||||
feature_cols=visible_items if 1<=visible_items<=4 else 3
|
||||
parts.append(start+f'<div class="sf-section-head"><h2>{title}</h2></div><div class="sf-features" style="--sf-feature-cols:{feature_cols}">{cards}</div>'+end)
|
||||
elif typ=='trust_bar':
|
||||
# Horizontal trust strip; item icons are free-text glyphs, empty ones
|
||||
# fall back to ordinal markers like the features block.
|
||||
items=block.get('items') if isinstance(block.get('items'),list) else []
|
||||
cells=''.join(f'<div class="sf-trust-item"><span class="sf-trust-icon" aria-hidden="true">{escape(str(item.get("icon") or (i+1)))}</span><strong>{escape(str(item.get("title","")))}</strong><span class="sf-trust-text">{escape(str(item.get("text","")))}</span></div>' for i,item in enumerate(items[:8]) if isinstance(item,dict))
|
||||
head=f'<div class="sf-section-head"><h2>{title}</h2></div>' if title else ''
|
||||
parts.append(start+head+f'<div class="sf-trust-bar">{cells}</div>'+end)
|
||||
elif typ=='applications_band':
|
||||
items=block.get('items') if isinstance(block.get('items'),list) else []
|
||||
tiles=''.join(f'<div class="sf-app-tile"><span class="sf-app-icon" aria-hidden="true">{escape(str(item.get("icon") or (i+1)))}</span><h3>{escape(str(item.get("title","")))}</h3><p>{escape(str(item.get("text","")))}</p></div>' for i,item in enumerate(items[:8]) if isinstance(item,dict))
|
||||
parts.append(start+f'<div class="sf-section-head"><div><span class="sf-kicker">{subtitle}</span><h2>{title}</h2></div></div><div class="sf-applications-band">{tiles}</div>'+end)
|
||||
elif typ=='stats':
|
||||
items=block.get('items') if isinstance(block.get('items'),list) else []
|
||||
cards=''.join(f'<div><strong>{escape(str(item.get("value","")))}</strong><span>{escape(str(item.get("label","")))}</span></div>' for item in items[:12] if isinstance(item,dict))
|
||||
|
||||
@@ -69,3 +69,23 @@
|
||||
@media(prefers-reduced-motion:reduce){.sf-hero-track{scroll-behavior:auto}}
|
||||
|
||||
.sf-hero-panel .sf-hero-copy h2{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}
|
||||
|
||||
/* Trust bar: horizontal credibility strip under the hero (icon + title + note). */
|
||||
.sf-section.sf-trust_bar{padding:38px 0}
|
||||
.sf-trust-bar{display:grid;grid-template-columns:repeat(var(--sf-trust-cols,4),minmax(0,1fr))}
|
||||
.sf-trust-item{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:10px 20px;border-inline-end:1px solid #dce3eb}
|
||||
.sf-trust-item:last-child{border-inline-end:0}
|
||||
.sf-trust-icon{display:grid;place-items:center;width:46px;height:46px;border-radius:50%;background:#eef2f8;color:var(--accent,#315ee2);font-size:18px;font-weight:800;margin-bottom:6px}
|
||||
.sf-trust-item strong{font-size:15px}
|
||||
.sf-trust-text{font-size:13px;opacity:.68;line-height:1.55}
|
||||
@media(max-width:820px){.sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:24px}.sf-trust-item:nth-child(2n){border-inline-end:0}}
|
||||
@media(max-width:540px){.sf-trust-bar{grid-template-columns:minmax(0,1fr)}.sf-trust-item{border-inline-end:0}}
|
||||
|
||||
/* Applications band: dark gradient strip with a 2x2 tile grid. */
|
||||
.sf-section.sf-applications_band{background:linear-gradient(115deg,#101828,#1d3a6e 78%);color:#f5f7fa}
|
||||
.sf-applications-band{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
|
||||
.sf-app-tile{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:26px 24px}
|
||||
.sf-app-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.12);font-size:17px;font-weight:800;margin-bottom:12px}
|
||||
.sf-app-tile h3{font-size:20px;margin:0 0 6px}
|
||||
.sf-app-tile p{font-size:14px;line-height:1.65;opacity:.75;margin:0}
|
||||
@media(max-width:540px){.sf-applications-band{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const pages=new Set(['home','listing','product','article','about','contact']);
|
||||
const categories={
|
||||
visual:new Set(['hero','banner','carousel','gallery','image','image_text','image-grid','video','slider']),
|
||||
business:new Set(['collection','products','product_list','brand_wall','brands','features','stats','testimonials','faq','contact','form','cta','map']),
|
||||
business:new Set(['collection','products','product_list','brand_wall','brands','features','stats','testimonials','faq','contact','form','cta','map','trust_bar','applications_band']),
|
||||
advanced:new Set(['layout','columns','tabs','accordion','html-embed','embed','spacer','divider'])
|
||||
};
|
||||
function categoryOf(key){for(const [name,ids] of Object.entries(categories))if(ids.has(key))return name;return 'basic'}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Task 13b: switch the HESU Pipe homepage from template-driven to block-driven.
|
||||
|
||||
Writes the 8-type block sequence into the site home entry through the real
|
||||
studio-draft API (login -> PUT draft -> publish -> build -> verify), so the
|
||||
page renders from blocks (layout_mode=replace) with the trade-pro block
|
||||
bridge instead of pages/home.html. The template file itself stays untouched.
|
||||
|
||||
Run AFTER the Docker image has been rebuilt with the trust_bar /
|
||||
applications_band block types, then:
|
||||
|
||||
PYTHONUTF8=1 .venv/Scripts/python scripts/setup_home_blocks.py
|
||||
|
||||
Reads SITEFORGE_ADMIN_PASSWORD / SITEFORGE_PORT from the repo .env.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app import blueprints # noqa: E402 -- local package for block defaults
|
||||
|
||||
SITE_ID = '483295c29ea0' # HESU Pipe (real import site, theme trade-pro)
|
||||
HOME_ENTRY_ID = 'c8ff1d1a45f3' # route '/' home entry
|
||||
|
||||
|
||||
def load_env() -> dict:
|
||||
values: dict[str, str] = {}
|
||||
env_path = ROOT / '.env'
|
||||
if env_path.is_file():
|
||||
for line in env_path.read_text(encoding='utf-8').splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, val = line.split('=', 1)
|
||||
values[key.strip()] = val.strip()
|
||||
return values
|
||||
|
||||
|
||||
def real_stats(entries: list[dict]) -> list[dict]:
|
||||
"""Derive stat items from actual catalogue counts - no invented numbers."""
|
||||
products = [e for e in entries if e.get('type') in ('product', 'vehicle')]
|
||||
posts = [e for e in entries if e.get('type') == 'post']
|
||||
|
||||
def field(entry, key):
|
||||
fields = (entry.get('meta') or {}).get('fields') or {}
|
||||
return str(fields.get(key) or '').strip()
|
||||
|
||||
categories = {field(p, 'category') for p in products if field(p, 'category')}
|
||||
brands = {field(p, 'brand') for p in products if field(p, 'brand')}
|
||||
items = [('Products', len(products)), ('Categories', len(categories)),
|
||||
('Brands', len(brands)), ('News updates', len(posts))]
|
||||
return [{'value': str(count), 'label': label} for label, count in items if count > 0]
|
||||
|
||||
|
||||
def build_blocks(entries: list[dict]) -> list[dict]:
|
||||
stats = real_stats(entries)
|
||||
sequence = [
|
||||
blueprints.new_block('hero', {
|
||||
'title': '{{site.name}}',
|
||||
'subtitle': 'GLOBAL SOURCING · MADE RELIABLE',
|
||||
'description': 'Reliable products, transparent specifications and responsive export service.',
|
||||
'image': '{{page.cover}}',
|
||||
'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',
|
||||
'description': 'Product specifications, quality control and export documentation in one place.',
|
||||
'image': '{{page.cover}}',
|
||||
'button': 'About our company', 'href': '/about/'}),
|
||||
blueprints.new_block('stats', {'items': stats}) if stats else None,
|
||||
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/'}),
|
||||
]
|
||||
return [block for block in sequence if block is not None]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env = load_env()
|
||||
password = env.get('SITEFORGE_ADMIN_PASSWORD') or ''
|
||||
if not password:
|
||||
print('ERROR: SITEFORGE_ADMIN_PASSWORD missing from .env')
|
||||
return 1
|
||||
port = (env.get('SITEFORGE_PORT') or '8787').strip() or '8787'
|
||||
base = f'http://127.0.0.1:{port}'
|
||||
|
||||
with httpx.Client(base_url=base, timeout=30) as client:
|
||||
health = client.get('/api/health')
|
||||
health.raise_for_status()
|
||||
print('server:', health.json())
|
||||
|
||||
login = client.post('/api/auth/login', json={'username': 'admin', 'password': password})
|
||||
if login.status_code != 200:
|
||||
print('ERROR: login failed:', login.text[:200])
|
||||
return 1
|
||||
client.headers['X-SiteForge-CSRF'] = client.get('/api/auth/me').json()['csrf']
|
||||
|
||||
blocks_catalog = client.get('/api/editor/blocks').json()['blocks']
|
||||
missing = [t for t in ('trust_bar', 'applications_band') if t not in blocks_catalog]
|
||||
if missing:
|
||||
print(f'ERROR: server does not know block types {missing} - rebuild the '
|
||||
'Docker image with the current source before running this script.')
|
||||
return 1
|
||||
|
||||
entries = client.get(f'/api/sites/{SITE_ID}/entries').json()
|
||||
entry = next((e for e in entries if e.get('route') == '/'), None)
|
||||
if not entry or entry['id'] != HOME_ENTRY_ID:
|
||||
print(f'ERROR: home entry {HOME_ENTRY_ID} (route /) not found on site {SITE_ID}')
|
||||
return 1
|
||||
|
||||
blocks = build_blocks(entries)
|
||||
blueprints.validate_blocks(blocks) # fail fast locally before touching the draft
|
||||
|
||||
state = client.get(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft').json()
|
||||
revision = int(state.get('revision') or 0)
|
||||
print(f"draft state: {state.get('state')} revision={revision}")
|
||||
|
||||
put = client.put(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft',
|
||||
json={'blocks': blocks, 'page_template': 'home',
|
||||
'layout_mode': 'replace', 'expected_revision': revision})
|
||||
if put.status_code != 200:
|
||||
print('ERROR: draft write failed:', put.status_code, put.text[:300])
|
||||
return 1
|
||||
next_revision = put.json()['revision']
|
||||
print(f'draft saved: revision={next_revision} blocks={len(blocks)} '
|
||||
f'types={[b["type"] for b in blocks]}')
|
||||
|
||||
publish = client.post(f'/api/sites/{SITE_ID}/pages/{HOME_ENTRY_ID}/studio-draft/publish',
|
||||
json={'expected_revision': next_revision})
|
||||
if publish.status_code != 200:
|
||||
print('ERROR: publish failed:', publish.status_code, publish.text[:300])
|
||||
return 1
|
||||
print('published to content library:', publish.json().get('message'))
|
||||
|
||||
before = {b['id'] for b in client.get(f'/api/sites/{SITE_ID}/builds').json()}
|
||||
key = 'task13b-home-' + uuid.uuid4().hex[:12]
|
||||
job = client.post(f'/api/sites/{SITE_ID}/builds', json={'mirror_images': False},
|
||||
headers={'Idempotency-Key': key})
|
||||
if job.status_code != 202:
|
||||
print('ERROR: build submit failed:', job.status_code, job.text[:300])
|
||||
return 1
|
||||
print('build queued:', job.json())
|
||||
|
||||
build_id = None
|
||||
for _ in range(120):
|
||||
time.sleep(2)
|
||||
rows = client.get(f'/api/sites/{SITE_ID}/builds').json()
|
||||
fresh = [b for b in rows if b['id'] not in before]
|
||||
done = [b for b in fresh if b['status'] == 'complete']
|
||||
if done:
|
||||
build_id = done[0]['id']
|
||||
break
|
||||
failed = [b for b in fresh if b['status'] in ('failed', 'error')]
|
||||
if failed:
|
||||
print('ERROR: build failed:', json.dumps(failed[0], ensure_ascii=False)[:400])
|
||||
return 1
|
||||
if not build_id:
|
||||
print('ERROR: build did not complete in time')
|
||||
return 1
|
||||
print('build complete:', build_id)
|
||||
|
||||
report = client.get(f'/api/sites/{SITE_ID}/builds/{build_id}/report').json()
|
||||
print('report: pages=%s errors=%s broken_links=%s missing_local_images=%s'
|
||||
% (report.get('pages'), len(report.get('errors') or []),
|
||||
len(report.get('broken_links') or []), len(report.get('missing_local_images') or [])))
|
||||
if report.get('errors') or report.get('broken_links'):
|
||||
print('ERROR: build report is not clean')
|
||||
return 1
|
||||
|
||||
page = client.get(f'/preview/{SITE_ID}/{build_id}/')
|
||||
page.raise_for_status()
|
||||
html = page.text
|
||||
# render_blocks always emits class="sf-section sf-{type}" as the opening
|
||||
# attribute, so this is an exact section-level check.
|
||||
absent = [t for t in ('hero', 'trust_bar', 'image_text', 'stats',
|
||||
'applications_band', 'features', 'cta')
|
||||
if f'class="sf-section sf-{t}"' not in html]
|
||||
if html.count('class="sf-section sf-collection"') < 2:
|
||||
absent.append('collection x2')
|
||||
gone = [m for m in ('sf-tp-hero', 'sf-tp-trustbar', 'sf-tp-two-col',
|
||||
'sf-tp-applications', 'sf-tp-feature-grid', 'sf-tp-cta') if m in html]
|
||||
print('block sections missing:', absent or 'none')
|
||||
print('template markers still present:', gone or 'none')
|
||||
if absent or gone:
|
||||
print('ERROR: homepage is not block-driven as expected')
|
||||
return 1
|
||||
print('PASS: homepage is now block-driven (layout_mode=replace), build clean.')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -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()
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
cta → sf-tp-cta(深色渐变带 + 幽灵按钮)、
|
||||
richtext → sf-tp-article-body 排版层;gallery/carousel/video/pricing/
|
||||
tabs 按同一令牌语汇补齐(cms 无一一对应组件)。
|
||||
Task 13b 新增:trust_bar → sf-tp-trustbar(白底横带 + icon-circle +
|
||||
发丝分隔线)、applications_band → sf-tp-applications(深色渐变带 +
|
||||
2×2 玻璃瓦片)——支撑首页从模板驱动切换为区块驱动(layout_mode=replace)。
|
||||
级联纪律:
|
||||
- 节级底色用 .sf-canvas 链(0,4,0)压过 pro-base 晚载的
|
||||
.pro-custom-content .sf-section(:nth-of-type(even))(静态构建 blocks 先于
|
||||
@@ -99,6 +102,26 @@
|
||||
|
||||
.sf-hero-panel .sf-hero-copy h2{font-size:clamp(38px,5vw,76px);font-weight:790;letter-spacing:-.052em}
|
||||
|
||||
/* Trust bar: horizontal credibility strip under the hero (icon + title + note). */
|
||||
.sf-section.sf-trust_bar{padding:38px 0}
|
||||
.sf-trust-bar{display:grid;grid-template-columns:repeat(var(--sf-trust-cols,4),minmax(0,1fr))}
|
||||
.sf-trust-item{display:flex;flex-direction:column;align-items:center;text-align:center;gap:6px;padding:10px 20px;border-inline-end:1px solid #dce3eb}
|
||||
.sf-trust-item:last-child{border-inline-end:0}
|
||||
.sf-trust-icon{display:grid;place-items:center;width:46px;height:46px;border-radius:50%;background:#eef2f8;color:var(--accent,#315ee2);font-size:18px;font-weight:800;margin-bottom:6px}
|
||||
.sf-trust-item strong{font-size:15px}
|
||||
.sf-trust-text{font-size:13px;opacity:.68;line-height:1.55}
|
||||
@media(max-width:820px){.sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:24px}.sf-trust-item:nth-child(2n){border-inline-end:0}}
|
||||
@media(max-width:540px){.sf-trust-bar{grid-template-columns:minmax(0,1fr)}.sf-trust-item{border-inline-end:0}}
|
||||
|
||||
/* Applications band: dark gradient strip with a 2x2 tile grid. */
|
||||
.sf-section.sf-applications_band{background:linear-gradient(115deg,#101828,#1d3a6e 78%);color:#f5f7fa}
|
||||
.sf-applications-band{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
|
||||
.sf-app-tile{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:10px;padding:26px 24px}
|
||||
.sf-app-icon{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:rgba(255,255,255,.12);font-size:17px;font-weight:800;margin-bottom:12px}
|
||||
.sf-app-tile h3{font-size:20px;margin:0 0 6px}
|
||||
.sf-app-tile p{font-size:14px;line-height:1.65;opacity:.75;margin:0}
|
||||
@media(max-width:540px){.sf-applications-band{grid-template-columns:minmax(0,1fr)}}
|
||||
|
||||
/* ════════ Layer B:trade-pro 视觉桥接(sf-tp-* 模板语汇 → sf-* 区块)════════ */
|
||||
|
||||
/* ── 画布守护(V8.33 既有约定):区块节不溢出栅格列、贴入图片不撑破版心;
|
||||
@@ -225,6 +248,27 @@
|
||||
.sf-theme-trade-pro .sf-attribute strong{color:var(--sf-tp-color-text-primary)}
|
||||
.sf-theme-trade-pro .sf-map-address{color:var(--sf-tp-color-text-secondary);font-size:var(--sf-tp-font-size-md)}
|
||||
|
||||
/* ── trust_bar → sf-tp-trustbar(Task 13b):白底横带 + icon-circle(brand-soft 底
|
||||
品牌字)+ 列间发丝分隔线(cms 首页信任条语汇)── */
|
||||
.sf-theme-trade-pro .sf-canvas .sf-section.sf-trust_bar{background:var(--sf-bg,var(--sf-tp-color-surface-default));padding-block:calc(var(--sf-pad,var(--sf-tp-space-10)) - var(--sf-tp-space-2))}
|
||||
.sf-theme-trade-pro .sf-trust-item{padding:var(--sf-tp-space-2) var(--sf-tp-space-4);gap:var(--sf-tp-space-1);border-inline-end:var(--sf-tp-border-width-hairline) solid var(--sf-tp-color-border-subtle)}
|
||||
.sf-theme-trade-pro .sf-trust-item:last-child{border-inline-end:0}
|
||||
.sf-theme-trade-pro .sf-trust-icon{width:var(--sf-tp-icon-size-xl);height:var(--sf-tp-icon-size-xl);margin-block-end:var(--sf-tp-space-2);border-radius:var(--sf-tp-radius-full);background:var(--sf-tp-color-surface-brand-soft);color:var(--sf-tp-color-text-brand);font-size:var(--sf-tp-font-size-md);box-shadow:var(--sf-tp-shadow-xs)}
|
||||
.sf-theme-trade-pro .sf-trust-item strong{color:var(--sf-tp-color-text-primary);font-size:var(--sf-tp-font-size-md)}
|
||||
.sf-theme-trade-pro .sf-trust-text{color:var(--sf-tp-color-text-muted);opacity:1;font-size:var(--sf-tp-font-size-sm);line-height:var(--sf-tp-line-height-normal)}
|
||||
|
||||
/* ── applications_band → sf-tp-applications(Task 13b):藏青→品牌蓝渐变带 +
|
||||
2×2 玻璃瓦片(inverse 透底 + 发丝透边 + 品牌圆标,cms 应用带语汇)── */
|
||||
.sf-theme-trade-pro .sf-canvas .sf-section.sf-applications_band{background:var(--sf-bg,linear-gradient(115deg,var(--sf-tp-color-surface-inverse),var(--sf-tp-color-brand-primary-deep) 78%));color:var(--sf-fg,var(--sf-tp-color-text-inverse))}
|
||||
.sf-theme-trade-pro .sf-section.sf-applications_band .sf-kicker{color:var(--sf-tp-color-text-inverse-muted)}
|
||||
.sf-theme-trade-pro .sf-section.sf-applications_band .sf-section-head h2{color:var(--sf-tp-color-text-inverse)}
|
||||
.sf-theme-trade-pro .sf-applications-band{gap:var(--sf-tp-space-4)}
|
||||
.sf-theme-trade-pro .sf-app-tile{background:color-mix(in srgb,var(--sf-tp-color-text-inverse) 7%,transparent);border:var(--sf-tp-border-width-hairline) solid color-mix(in srgb,var(--sf-tp-color-text-inverse) 18%,transparent);border-radius:var(--sf-tp-card-border-radius);padding:var(--sf-tp-space-5);transition:transform var(--sf-tp-duration-fast) var(--sf-tp-easing-standard),box-shadow var(--sf-tp-duration-fast) var(--sf-tp-easing-standard)}
|
||||
.sf-theme-trade-pro .sf-app-tile:hover{transform:translateY(calc(-1 * var(--sf-tp-space-1)));box-shadow:var(--sf-tp-shadow-md)}
|
||||
.sf-theme-trade-pro .sf-app-icon{width:var(--sf-tp-icon-size-xl);height:var(--sf-tp-icon-size-xl);margin-block-end:var(--sf-tp-space-3);border-radius:var(--sf-tp-radius-full);background:var(--sf-tp-color-surface-brand);color:var(--sf-tp-color-text-on-brand);font-size:var(--sf-tp-font-size-md)}
|
||||
.sf-theme-trade-pro .sf-app-tile h3{color:var(--sf-tp-color-text-inverse);font-size:var(--sf-tp-font-size-md);margin-block-end:var(--sf-tp-space-1)}
|
||||
.sf-theme-trade-pro .sf-app-tile p{color:color-mix(in srgb,var(--sf-tp-color-text-inverse) 80%,transparent);opacity:1;font-size:var(--sf-tp-font-size-sm);line-height:var(--sf-tp-line-height-normal);margin:0}
|
||||
|
||||
/* ── 桥接层响应式(断点随模板:900 栅格堆叠 / 620 紧凑;Layer A 的 820/540
|
||||
设备控制语义由 responsive.py 继续接管 padding/显隐)── */
|
||||
@media (max-width: 900px){
|
||||
@@ -234,6 +278,8 @@
|
||||
.sf-theme-trade-pro .sf-collection{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.sf-theme-trade-pro .sf-gallery{grid-template-columns:repeat(2,1fr)}
|
||||
.sf-theme-trade-pro .sf-cta-wrap{align-items:flex-start;flex-direction:column}
|
||||
.sf-theme-trade-pro .sf-trust-bar{grid-template-columns:repeat(2,minmax(0,1fr));row-gap:var(--sf-tp-space-6)}
|
||||
.sf-theme-trade-pro .sf-trust-item:nth-child(2n){border-inline-end:0}
|
||||
}
|
||||
@media (max-width: 620px){
|
||||
.sf-theme-trade-pro .sf-section.sf-hero .sf-hero-grid{gap:var(--sf-tp-space-4)}
|
||||
@@ -251,12 +297,18 @@
|
||||
.sf-theme-trade-pro .sf-section-head>a{display:inline-block;margin-block-start:var(--sf-tp-space-2)}
|
||||
.sf-theme-trade-pro .sf-cta-wrap p{font-size:var(--sf-tp-font-size-sm)}
|
||||
.sf-theme-trade-pro .sf-price{padding:var(--sf-tp-card-padding-md)}
|
||||
.sf-theme-trade-pro .sf-trust-bar{grid-template-columns:minmax(0,1fr)}
|
||||
.sf-theme-trade-pro .sf-trust-item{border-inline-end:0;padding:var(--sf-tp-space-2)}
|
||||
.sf-theme-trade-pro .sf-applications-band{gap:var(--sf-tp-space-3)}
|
||||
.sf-theme-trade-pro .sf-app-tile{padding:var(--sf-tp-space-4)}
|
||||
}
|
||||
/* 动效尊重系统偏好(Layer A 同款契约,覆盖本层新增过渡)*/
|
||||
@media(prefers-reduced-motion:reduce){
|
||||
.sf-theme-trade-pro .sf-item,.sf-theme-trade-pro .sf-item-img img,.sf-theme-trade-pro .sf-gallery img,.sf-theme-trade-pro .sf-button{transition:none}
|
||||
.sf-theme-trade-pro .sf-item:hover,.sf-theme-trade-pro .sf-button:hover{transform:none}
|
||||
.sf-theme-trade-pro .sf-item:hover .sf-item-img img,.sf-theme-trade-pro .sf-gallery img:hover{transform:none}
|
||||
.sf-theme-trade-pro .sf-app-tile{transition:none}
|
||||
.sf-theme-trade-pro .sf-app-tile:hover{transform:none}
|
||||
}
|
||||
/* 画布列内区块的移动端收边(V8.33 既有约定)*/
|
||||
@media(max-width:640px){.sf-theme-trade-pro .sf-layout-column{min-width:0}.sf-theme-trade-pro .sf-canvas .sf-section{padding-inline:clamp(12px,4vw,24px)}}
|
||||
|
||||
Reference in New Issue
Block a user