feat: workspace UX — nav quick-access, content type tabs, list pagination

- 外观与设计: prominent 导航菜单 card linking to editor's siteDesign
- 内容与页面: type tab pills (全部/产品/文章/页面/...) with counts,
  replaces dropdown; content list paginated 20/page with prev/next
- Search resets page; tab click resets page

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-28 11:30:19 +08:00
parent df2a183dbf
commit 85e6d4aeca
3 changed files with 242 additions and 150 deletions
+52 -11
View File
@@ -182,11 +182,13 @@ def test_trade_pro_home_layout_structure(factory):
def test_trade_pro_product_layout_structure(factory):
# Task 10b:product.html 移植 cms 产品详情设计([slug].astro)后的结构契约。
# Task 12a:product.html 按 cms [slug].astro 精确对齐重做后的结构契约。
# 构建流(/preview/ HTTP)+ 带 fields/gallery 的产品条目驱动断言:
# 画廊(主图封面 + data-gallery-image 缩略图,复用平台 pro.js 增强钩子)、
# 标题区、参数表渲染 fields 键值(品牌等管理字段不进表)、询盘 CTA、
# 相关产品复用首页卡片网格;旧骨架 .pro-product-layout/.pro-specs 无残留。
# hero 画廊(pro.js 钩子)+ 标题区(cms 产品页无 h1,产品名 h2 量级);
# sticky 选项卡导航 + 面板(overview 恒有 / specifications 有数据才显);
# 无 JS 平铺回退(静态标记不带 tabs-enabled、面板无内联隐藏);
# rich-body 正文表格样式 + spec-table 两对键值一行 + 面板标题品牌色下划短线;
# 询盘/相关产品沿用既有实现;旧骨架 .pro-product-layout/.pro-specs 无残留。
sid = site(factory)
assert factory.post(f'/api/factory/sites/{sid}/bootstrap').status_code == 200
made = factory.post(f'/api/sites/{sid}/entries', json={
@@ -210,26 +212,65 @@ def test_trade_pro_product_layout_structure(factory):
assert media is not None and 'hp-200.jpg' in media.get('src', '')
thumbs = soup.select('.sf-tp-product-thumbs [data-gallery-image]')
assert len(thumbs) == 2
# 标题区(唯一 h1)+ 询盘 CTA 指向联系页(/preview/ 响应会把根相对
# href 重写为 /preview/{sid}/{bid}/contact/,故用后缀匹配)
assert soup.select_one('.sf-tp-product-summary h1.sf-tp-product-title')
# 标题区:cms 对齐——产品名 h2(2xl 量级,cms 产品页全页无 h1)+ 询盘 CTA
# (/preview/ 响应会把根相对 href 重写为 /preview/{sid}/{bid}/contact/,故用后缀匹配)
assert soup.select_one('.sf-tp-product-summary h2.sf-tp-product-title')
assert not soup.find_all('h1') # cms [slug].astro 产品页标题层级从 h2 起
assert soup.select_one('.sf-tp-product-actions a.sf-tp-btn[href$="/contact/"]')
# 参数表:fields 键值渲染(下划线转空格 + 首字母大写),空表整段隐藏
# 选项卡导航:sticky 容器 + 单行横滚 tabs-row;首个 active,第二个有 specs 才显
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 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
# 参数表:cms spec-table 每行两对键值(2 条 specs → 1 行 2 th + 2 td);
# fields 键值渲染(下划线转空格 + 首字母大写),品牌等管理字段不进表
spec_table = soup.select_one('table.sf-tp-spec-table')
assert spec_table is not None
rows = spec_table.select('tbody tr')
assert len(rows) == 1
assert len(rows[0].select('th')) == 2 and len(rows[0].select('td')) == 2
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 # brand 等管理字段不进参数表
assert 'Quanta' not in spec_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 移动端横滚
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-detail-section > .wrap > h2{',
'width:100% !important',
'height:auto !important',
'.sf-tp-spec-table{min-width:720px}',
):
assert needle in pro_css, needle
# 旧骨架清理:无 .pro-product-layout/.pro-specs 残留类依赖
assert 'pro-product-layout' not in html and 'pro-specs' not in html
assert len(soup.find_all('h1')) == 1
def test_trade_pro_listing_layout_structure(factory):
# Task 10c:listing.html(category.html 同构复用)移植 cms 列表设计后的结构契约。