diff --git a/app/blueprints.py b/app/blueprints.py index fd668c2..79d085f 100644 --- a/app/blueprints.py +++ b/app/blueprints.py @@ -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'

{title}

{cards}
'+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'
{escape(str(item.get("title","")))}{escape(str(item.get("text","")))}
' for i,item in enumerate(items[:8]) if isinstance(item,dict)) + head=f'

{title}

' if title else '' + parts.append(start+head+f'
{cells}
'+end) + elif typ=='applications_band': + items=block.get('items') if isinstance(block.get('items'),list) else [] + tiles=''.join(f'

{escape(str(item.get("title","")))}

{escape(str(item.get("text","")))}

' for i,item in enumerate(items[:8]) if isinstance(item,dict)) + parts.append(start+f'
{subtitle}

{title}

{tiles}
'+end) elif typ=='stats': items=block.get('items') if isinstance(block.get('items'),list) else [] cards=''.join(f'
{escape(str(item.get("value","")))}{escape(str(item.get("label","")))}
' for item in items[:12] if isinstance(item,dict)) diff --git a/app/static/blocks.css b/app/static/blocks.css index cca3b6f..a2e9f92 100644 --- a/app/static/blocks.css +++ b/app/static/blocks.css @@ -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)}} diff --git a/app/static/studio-experience-v829.js b/app/static/studio-experience-v829.js index 8f94b32..2e4951a 100644 --- a/app/static/studio-experience-v829.js +++ b/app/static/studio-experience-v829.js @@ -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'} diff --git a/scripts/setup_home_blocks.py b/scripts/setup_home_blocks.py new file mode 100644 index 0000000..800feab --- /dev/null +++ b/scripts/setup_home_blocks.py @@ -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()) diff --git a/tests/test_theme_registry.py b/tests/test_theme_registry.py index 5b9838a..5a78a71 100644 --- a/tests/test_theme_registry.py +++ b/tests/test_theme_registry.py @@ -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() diff --git a/themes/trade-pro/blocks.css b/themes/trade-pro/blocks.css index 9b43cb2..5723e87 100644 --- a/themes/trade-pro/blocks.css +++ b/themes/trade-pro/blocks.css @@ -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)}}