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:
+15
-6
@@ -7,7 +7,7 @@ const typeName={page:'页面',product:'产品',vehicle:'车型',post:'新闻',br
|
||||
const fmtDate=t=>{if(!t)return '—';const d=new Date(t);return Number.isNaN(d.getTime())?String(t).slice(0,16):d.toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit'})};
|
||||
const sfSiteKey='siteforge:selected-site';
|
||||
const sfRemember=id=>{try{if(id)localStorage.setItem(sfSiteKey,id)}catch{}};
|
||||
const state={launchDeploying:false,releaseRequestSeq:0,releaseRequests:[],releaseRequestsKnown:false,launchBuildId:'',launchTarget:'staging',launchReview:false,launchPreflight:null,sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',loading:false,job:null,selectionToken:0,building:false,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};
|
||||
const state={launchDeploying:false,releaseRequestSeq:0,releaseRequests:[],releaseRequestsKnown:false,launchBuildId:'',launchTarget:'staging',launchReview:false,launchPreflight:null,sites:[],site:null,entries:[],builds:[],jobs:[],view:'home',csrf:'',search:'',typ:'',page:1,loading:false,job:null,selectionToken:0,building:false,delivery:{jobs:[],history:[],configured:{}},language:{group:null,sites:[],units:[]},languageGroups:[],languageEntries:[]};const PAGE_SIZE=20;
|
||||
const headings={home:'工作台',sites:'我的网站',guide:'建站操作指引',launch:'上线操作中心',overview:'网站概览',content:'内容与页面',design:'外观与设计',import:'导入与迁移',build:'生成与预览',languages:'多语言管理',settings:'网站设置'};
|
||||
async function api(url,method='GET',data=null,extraHeaders={}){
|
||||
const headers={...extraHeaders};if(method!=='GET'&&method!=='HEAD')headers['X-SiteForge-CSRF']=state.csrf;
|
||||
@@ -118,15 +118,24 @@ function launch(){
|
||||
}
|
||||
|
||||
function content(){if(!currentSite())return noSite();let arr=state.entries.filter(e=>(!state.typ||e.type===state.typ)&&(!state.search||`${e.title} ${e.route}`.toLowerCase().includes(state.search.toLowerCase())));
|
||||
const totalPages=Math.max(1,Math.ceil(arr.length/PAGE_SIZE));
|
||||
if(state.page>totalPages)state.page=1;
|
||||
const start=(state.page-1)*PAGE_SIZE;
|
||||
const pageItems=arr.slice(start,start+PAGE_SIZE);
|
||||
const typeCounts={};state.entries.forEach(e=>typeCounts[e.type]=(typeCounts[e.type]||0)+1);
|
||||
const tabs=[{key:'',label:'全部',count:state.entries.length},...Object.entries(typeName).filter(([k])=>typeCounts[k]).map(([key,value])=>({key,label:value,count:typeCounts[key]}))];
|
||||
const tabHtml=tabs.map(t=>`<button class=”btn btn-tiny ${(!state.typ&&t.key==='')||(state.typ===t.key)?'btn-primary':'btn-muted'}” style=”margin:0 4px 8px 0” data-action=”set-type-tab” data-type=”${t.key}”>${t.label} (${t.count})</button>`).join('');
|
||||
const pagination=totalPages>1?`<div style=”display:flex;align-items:center;gap:12px;justify-content:center;padding:16px 0”><button class=”btn btn-tiny” data-action=”page-prev” ${state.page<=1?'disabled':''}>‹ 上一页</button><span class=”muted”>第 ${state.page} / ${totalPages} 页 · 共 ${arr.length} 条</span><button class=”btn btn-tiny” data-action=”page-next” ${state.page>=totalPages?'disabled':''}>下一页 ›</button></div>`:'';
|
||||
return htmlHeading('内容与页面','所有网页、产品、车型和新闻都在这里维护,保存后重新构建即可更新网站。',actionButton('+ 添加内容','new-entry'))+
|
||||
`<div class="panel"><div class="panel-head"><div><h2>内容列表</h2><p>共 ${state.entries.length} 条内容。点击“设计页面”可进行可视化排版。</p></div></div><div class="toolbar"><input id="contentSearch" aria-label="搜索内容" placeholder="搜索标题或页面路径…" value="${esc(state.search)}"><select id="contentType" aria-label="按类型筛选"><option value="">全部类型</option>${Object.entries(typeName).map(([key,value])=>`<option value="${key}" ${state.typ===key?'selected':''}>${value}</option>`).join('')}</select></div>`+
|
||||
(arr.length?`<div class="table-wrap"><table class="data-table"><thead><tr><th>名称</th><th>内容类型</th><th>页面地址</th><th>状态</th><th>操作</th></tr></thead><tbody>${arr.slice(0,200).map(e=>`<tr><td><strong>${esc(e.title)}</strong></td><td>${esc(typeName[e.type]||e.type)}</td><td class="muted">${esc(e.route)}</td><td><span class="pill ${e.status==='draft'?'warn':'success'}">${e.status==='draft'?'草稿':'已保存'}</span></td><td class="action-cell"><button class="btn btn-tiny" data-action="edit-entry" data-id="${esc(e.id)}">修改内容</button> <a class="btn btn-tiny" href="/editor/${encodeURIComponent(state.site.id)}/${encodeURIComponent(e.id)}">设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div>`:emptyState('没有找到内容','可以清除筛选条件,或者添加新内容。',actionButton('+ 添加内容','new-entry')))+
|
||||
`${arr.length>200?'<p class="muted">当前显示前 200 条,请使用搜索缩小范围。</p>':''}</div>`;
|
||||
`<div class=”panel”><div class=”panel-head”><div><h2>内容列表</h2><p>共 ${state.entries.length} 条内容。点击”设计页面”可进行可视化排版。</p></div></div><div style=”margin-bottom:12px”>${tabHtml}</div><div class=”toolbar”><input id=”contentSearch” aria-label=”搜索内容” placeholder=”搜索标题或页面路径…” value=”${esc(state.search)}”></div>`+
|
||||
(pageItems.length?`<div class=”table-wrap”><table class=”data-table”><thead><tr><th>名称</th><th>内容类型</th><th>页面地址</th><th>状态</th><th>操作</th></tr></thead><tbody>${pageItems.map(e=>`<tr><td><strong>${esc(e.title)}</strong></td><td>${esc(typeName[e.type]||e.type)}</td><td class=”muted”>${esc(e.route)}</td><td><span class=”pill ${e.status==='draft'?'warn':'success'}”>${e.status==='draft'?'草稿':'已保存'}</span></td><td class=”action-cell”><button class=”btn btn-tiny” data-action=”edit-entry” data-id=”${esc(e.id)}”>修改内容</button> <a class=”btn btn-tiny” href=”/editor/${encodeURIComponent(state.site.id)}/${encodeURIComponent(e.id)}”>设计页面 ↗</a></td></tr>`).join('')}</tbody></table></div>${pagination}`:emptyState('没有找到内容','可以清除筛选条件,或者添加新内容。',actionButton('+ 添加内容','new-entry')))+
|
||||
`</div>`;
|
||||
}
|
||||
function design(){if(!currentSite())return noSite();const s=state.site;const cards=Object.entries(themeName).map(([id,name])=>`<div class="theme-card"><div class="theme-preview ${id}">${name}</div><div class="theme-desc"><h3>${name} ${s.theme===id?'<span class="pill success">当前使用</span>':''}</h3><p>${({automotive:'适合汽车出口、车型及品牌展示',industrial:'适合制造业、产品与技术参数',corporate:'适合集团公司、服务和案例',minimal:'适合作品展示和品牌形象','trade-pro':'适合外贸 B2B 英文站点与询盘'})[id]}</p><button class="btn btn-tiny ${s.theme===id?'btn-muted':'btn-primary'}" ${s.theme===id?'disabled':''} data-action="set-theme" data-theme="${id}">${s.theme===id?'正在使用':'使用此主题'}</button><a class="btn btn-tiny btn-muted" href="/theme-gallery?theme=${encodeURIComponent(id)}" target="_blank" rel="noopener">预览页面效果</a></div></div>`).join('');
|
||||
return htmlHeading('外观与设计','先选一套网站风格,再打开可视化编辑器修改图文、导航和手机端布局。')+
|
||||
`<div class="section-heading"><div><h2>第一步 · 选择行业主题</h2><p>更换主题不会删除您已经录入的业务内容和 URL。</p></div><a href="/theme-gallery" class="btn btn-tiny">查看完整主题预览 ↗</a></div><div class="choice-cards">${cards}</div>`+
|
||||
`<div class="section-heading"><div><h2>第二步 · 设计网页</h2><p>挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。</p></div></div><div class="panel"><div class="panel-head"><div><h2>选择要设计的页面</h2><p>修改先保存为草稿,正式发布设计后才会参与下一次静态构建。</p></div></div><div class="grid-2 grid-equal">${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`<a class="site-card" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent(e.id)}"><span class="site-icon">▤</span><span><h3>${esc(e.title)}</h3><p>${esc(e.route)}</p></span><span class="arrow">›</span></a>`).join('')||'<div class="muted">尚无页面,请先添加内容。</div>'}</div></div><div class="help-hint" style="margin-top:19px"><strong>提示</strong>:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。</div>`;
|
||||
`<div class="section-heading"><div><h2>第二步 · 导航菜单</h2><p>自定义网站的顶部导航:添加、删除、排序菜单项,支持三级下拉。</p></div></div><div class="panel" id="navQuickPanel"><div class="panel-head"><div><h2>☰ 网站导航菜单</h2><p>直接编辑菜单文字和链接,改完保存并重新构建即可生效。</p></div><a class="btn btn-primary" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent((state.entries.find(e=>e.type==='page')||state.entries[0]||{id:''}).id)}" title="打开可视化编辑器中的全站设计面板">编辑导航菜单 →</a></div><p class="muted" style="padding:4px 0">点击后进入页面编辑器,选择右上角「全站页眉与页脚」即可修改导航。</p></div>`+
|
||||
`<div class="section-heading"><div><h2>第三步 · 设计网页</h2><p>挑选要修改的页面;可在设计器中编辑导航、页眉、页脚及各设备布局。</p></div></div><div class="panel"><div class="panel-head"><div><h2>选择要设计的页面</h2><p>修改先保存为草稿,正式发布设计后才会参与下一次静态构建。</p></div></div><div class="grid-2 grid-equal">${state.entries.filter(e=>e.type==='page').slice(0,8).map(e=>`<a class="site-card" href="/editor/${encodeURIComponent(s.id)}/${encodeURIComponent(e.id)}"><span class="site-icon">▤</span><span><h3>${esc(e.title)}</h3><p>${esc(e.route)}</p></span><span class="arrow">›</span></a>`).join('')||'<div class="muted">尚无页面,请先添加内容。</div>'}</div></div><div class="help-hint" style="margin-top:19px"><strong>提示</strong>:网站设置用于公司信息和域名;可视化编辑器用于调整页面外观。两者不会混在同一表单里。</div>`;
|
||||
}
|
||||
function imports(){if(!currentSite())return noSite();return htmlHeading('导入与迁移','根据您已有的资料选择操作方式:Excel 产品、WordPress 网站,或者外部 API / 数据库。')+
|
||||
`<div class="quick-grid"><a class="quick-card" href="/production-v8"><span class="quick-ico">▤</span><span><h3>导入 Excel / CSV</h3><p>推荐先预览字段、检查冲突,再确认提交。</p><span class="quick-more">安全导入与批次回滚 →</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⇄</span><span><h3>迁移 WordPress 旧站</h3><p>迁移会话、文章采集、图片与 SEO 检查。</p><span class="quick-more">打开迁移工作台 ↗</span></span></a><a class="quick-card" href="/production-v8"><span class="quick-ico">⌘</span><span><h3>API / XML / MySQL</h3><p>连接外部业务系统,进行字段映射和分批导入。</p><span class="quick-more">进入高级导入 ↗</span></span></a></div>`+
|
||||
@@ -303,7 +312,7 @@ async function init(){try{const me=await api('/api/auth/me');state.csrf=me.csrf;
|
||||
else {state.view=target==='sites'?'sites':'home';rerender();rememberView(true)}
|
||||
}catch(e){notifyError(e);$('#mainContent').innerHTML=emptyState('后台加载失败',e.message,'<button class="btn btn-primary" onclick="location.reload()">重新加载</button>')}}
|
||||
// Delegated UI interactions keep navigation and form actions centrally auditable.
|
||||
document.addEventListener('click',async ev=>{const sidebar=ev.target.closest('.nav-item[data-view]');if(sidebar&&!sidebar.hasAttribute('data-action')){navigate(sidebar.dataset.view);return;}const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='language-refresh')await loadLanguage();if(action==='new-site')openCreateWizard();if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);if(action==='report')await showReport(control.dataset.id);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id);if(action==='launch-refresh')await launchRefresh();if(action==='launch-build')await buildNow();if(action==='launch-report')await launchReport(control.dataset.id);if(action==='launch-request-approval')await launchRequestApproval();if(action==='launch-preflight')await launchPreflight();if(action==='launch-deploy')await launchDeploy()}catch(e){notifyError(e)}});
|
||||
document.addEventListener('click',async ev=>{const sidebar=ev.target.closest('.nav-item[data-view]');if(sidebar&&!sidebar.hasAttribute('data-action')){navigate(sidebar.dataset.view);return;}const control=ev.target.closest('[data-action]');if(!control)return;const action=control.dataset.action;try{if(action==='navigate')navigate(control.dataset.view);if(action==='language-refresh')await loadLanguage();if(action==='set-type-tab'){state.typ=control.dataset.type;state.page=1;rerender()}if(action==='page-prev'){state.page=Math.max(1,state.page-1);rerender()}if(action==='page-next'){state.page++;rerender()}if(action==='new-site')openCreateWizard();if(action==='select-site')await selectSite(control.dataset.id);if(action==='new-entry')openEntry();if(action==='edit-entry')openEntry(control.dataset.id);if(action==='set-theme')await setTheme(control.dataset.theme);if(action==='build-now')await buildNow();if(action==='refresh-builds')await refreshBuilds();if(action==='check-readiness')await checkReadiness();if(action==='cancel-job')await cancelJob(control.dataset.id);if(action==='report')await showReport(control.dataset.id);if(action==='release-preflight')await releasePreflight();if(action==='release-approval')await releaseApproval();if(action==='release-deploy')await releaseDeploy();if(action==='release-rollback')await releaseRollback(control.dataset.target,control.dataset.id);if(action==='launch-refresh')await launchRefresh();if(action==='launch-build')await buildNow();if(action==='launch-report')await launchReport(control.dataset.id);if(action==='launch-request-approval')await launchRequestApproval();if(action==='launch-preflight')await launchPreflight();if(action==='launch-deploy')await launchDeploy()}catch(e){notifyError(e)}});
|
||||
document.addEventListener('click',ev=>{const btn=ev.target.closest('[data-close]');if(btn)closeModal(btn.dataset.close)});
|
||||
$('#siteForm').addEventListener('submit',saveSite);$('#entryForm').addEventListener('submit',saveEntry);
|
||||
document.addEventListener('submit',ev=>{if(ev.target.id==='settingsForm')saveSettings(ev);if(ev.target.id==='importForm')importFile(ev);if(ev.target.id==='langGroupForm')submitLanguageGroup(ev);if(ev.target.id==='langJoinForm')submitLanguageJoin(ev);if(ev.target.id==='langUnitForm')submitLanguageUnit(ev);if(ev.target.id==='langLinkForm')submitLanguageLink(ev)});
|
||||
|
||||
@@ -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 列表设计后的结构契约。
|
||||
|
||||
+175
-133
@@ -1,145 +1,187 @@
|
||||
{# trade-pro product detail — cms 产品详情设计移植(Task 10b)。
|
||||
区块序列对照 D:/project/cms/runtimes/astro-site-runtime/src/pages/[locale]/products/[slug].astro
|
||||
(像素基准 dist/hesupipe/en/products/alloy-resin-system-window/):
|
||||
面包屑 → 画廊(主图 + 单行横滚缩略图)+ 标题区(眉题/h1/导语/品牌行/
|
||||
CTA 对)→ 正文 Product Details → 参数表 Specifications(fields 键值,
|
||||
空则整段隐藏)→ 相关产品(卡片网格)→ 询盘板块(左联系信息 + 右 CTA/
|
||||
外部表单)。
|
||||
与 cms 的两处结构决策:
|
||||
1. Tab 导航平铺:cms 的 Tab 序列承载每产品自定义区块,平台正文是单一
|
||||
富文本 + 参数表,仅两个面板不值得引入 JS Tab;cms 无 JS 回退形态
|
||||
本就是面板平铺,这里固定为平铺(无 JS 依赖、静态可抓取)。
|
||||
2. 画廊交互复用平台 pro.js 既有钩子(.pro-product-media /
|
||||
.pro-gallery-thumbs / [data-gallery-image]):缩略图点击换主图 +
|
||||
主图灯箱,零新增 JS;无 JS 时全部图片仍平铺可读。无图回退封面,
|
||||
再无则占位渐变(空值不破版)。 #}
|
||||
{# trade-pro product detail — cms 产品详情精确对齐重做(Task 12a,重做 10b)。
|
||||
设计源(唯一权威依据)D:/project/cms/runtimes/astro-site-runtime/src/pages/
|
||||
[locale]/products/[slug].astro,区块序列逐块对照:
|
||||
detail-hero(面包屑 + 1.08/0.92 双栏:画廊/标题区,cms 产品名 h2 全页无 h1)
|
||||
→ product-tabbed(sticky 选项卡:Product Details 正文恒有 + Specifications
|
||||
参数表有数据才显)→ related-detail(sf-tp-card 卡片网格)→ quote-section
|
||||
(左联系信息 + 右 CTA/外部表单)。
|
||||
与 cms 的三处结构决策:
|
||||
1. 选项卡系统照搬 cms:容器 data-sf-tp-tabbed + .sf-tp-panel/.active 面板 +
|
||||
尾部内联 <script> 切换(运行时给容器加 sf-tp-tabs-enabled,CSS 只显示
|
||||
active 面板并隐藏 panel-title)——无 JS 时默认形态即 cms 回退形态:面板
|
||||
全部平铺可读、panel-title 可见,静态可抓取。
|
||||
2. 正文不拆表:WordPress 规格表 <table> 直接在 body HTML 里,正文整体作为
|
||||
Product Details 面板渲染(.sf-tp-product-body rich-body 表格样式:全宽/
|
||||
发丝框/斑马纹/覆盖内联宽高/单元格 div 净空);specs 变量有数据时另设
|
||||
Specifications 面板(干净 spec-table,每行两对键值,从 fields 读)。
|
||||
3. 画廊交互复用平台 pro.js 既有钩子(.pro-product-media /
|
||||
.pro-gallery-thumbs / [data-gallery-image]):缩略图点击换主图 + 主图灯箱,
|
||||
零新增 JS;无 JS 时全部图片仍平铺可读。无图回退封面,再无则占位渐变
|
||||
(空值不破版)。 #}
|
||||
<section class="sf-tp-product-hero">
|
||||
<div class="wrap">
|
||||
<nav class="sf-tp-breadcrumb" aria-label="Breadcrumb" data-reveal>
|
||||
<a href="/">Home</a><a href="/products/">Products</a><span class="sf-tp-breadcrumb-current">{{ page.title }}</span>
|
||||
</nav>
|
||||
<div class="sf-tp-product-grid">
|
||||
{# pro.js 经 .pro-gallery-thumbs 的 parentElement 定位 .pro-product-media img——
|
||||
缩略图容器必须保持为 .pro-product-media 的兄弟节点(不可外包滚动容器) #}
|
||||
<div class="sf-tp-product-gallery" data-reveal>
|
||||
{% if gallery %}
|
||||
<div class="sf-tp-product-media pro-product-media">
|
||||
<img class="sf-tp-product-main-img" src="{{ gallery[0] }}" alt="{{ page.title }}" width="800" height="600" loading="eager" decoding="async">
|
||||
</div>
|
||||
{% if gallery|length > 1 %}
|
||||
<div class="sf-tp-product-thumbs pro-gallery-thumbs" aria-label="Product image gallery">
|
||||
{% for photo in gallery %}
|
||||
<button type="button" class="sf-tp-product-thumb" data-gallery-image="{{ photo }}" aria-label="View image {{ loop.index }}" aria-pressed="{{ 'true' if loop.first else 'false' }}"><img src="{{ photo }}" alt="{{ page.title }} image {{ loop.index }}" width="150" height="100" loading="lazy" decoding="async"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="sf-tp-product-media pro-product-media">
|
||||
<div class="sf-tp-photo-plain" aria-hidden="true"><span>PRODUCT</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sf-tp-product-summary" data-reveal style="--reveal-i: 1">
|
||||
<span class="eyebrow">PRODUCT DETAILS / {{ fields.get('brand') or 'CATALOGUE' }}</span>
|
||||
<h1 class="sf-tp-product-title">{{ page.title }}</h1>
|
||||
<p class="sf-tp-product-lead">{{ page.summary or 'Detailed product information, specifications and export packaging are published on this page.' }}</p>
|
||||
{% if fields.get('brand') or fields.get('model') %}
|
||||
<p class="sf-tp-product-meta">{% if fields.get('brand') %}<span>Brand · {{ fields.get('brand') }}</span>{% endif %}{% if fields.get('model') %}<span>Model · {{ fields.get('model') }}</span>{% endif %}</p>
|
||||
{% endif %}
|
||||
<div class="sf-tp-product-actions">
|
||||
<a class="sf-tp-btn" href="/contact/">Get a quote {{ icon('arrow-right') }}</a>
|
||||
<a class="sf-tp-btn sf-tp-btn--ghost" href="/products/">All products {{ icon('arrow-right') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<nav class="sf-tp-breadcrumb" aria-label="Breadcrumb" data-reveal>
|
||||
<a href="/">Home</a><a href="/products/">Products</a><span class="sf-tp-breadcrumb-current">{{ page.title }}</span>
|
||||
</nav>
|
||||
<div class="sf-tp-product-grid">
|
||||
{# pro.js 经 .pro-gallery-thumbs 的 parentElement 定位 .pro-product-media img——
|
||||
缩略图容器必须保持为 .pro-product-media 的兄弟节点(不可外包滚动容器) #}
|
||||
<div class="sf-tp-product-gallery" data-reveal>
|
||||
{% if gallery %}
|
||||
<div class="sf-tp-product-media pro-product-media">
|
||||
<img class="sf-tp-product-main-img" src="{{ gallery[0] }}" alt="{{ page.title }}" width="800" height="600" loading="eager" decoding="async">
|
||||
</div>
|
||||
{% if gallery|length > 1 %}
|
||||
<div class="sf-tp-product-thumbs pro-gallery-thumbs" aria-label="Product image gallery">
|
||||
{% for photo in gallery %}
|
||||
<button type="button" class="sf-tp-product-thumb" data-gallery-image="{{ photo }}" aria-label="View image {{ loop.index }}" aria-pressed="{{ 'true' if loop.first else 'false' }}"><img src="{{ photo }}" alt="{{ page.title }} image {{ loop.index }}" width="150" height="100" loading="lazy" decoding="async"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="sf-tp-product-media pro-product-media">
|
||||
<div class="sf-tp-photo-plain" aria-hidden="true"><span>PRODUCT</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="sf-tp-product-summary" data-reveal style="--reveal-i: 1">
|
||||
<span class="eyebrow">PRODUCT DETAILS / {{ fields.get('brand') or 'CATALOGUE' }}</span>
|
||||
{# cms:产品名 h2 量级(2xl 非巨型,[slug].astro 全页无 h1) #}
|
||||
<h2 class="sf-tp-product-title">{{ page.title }}</h2>
|
||||
<p class="sf-tp-product-lead">{{ page.summary or 'Detailed product information, specifications and export packaging are published on this page.' }}</p>
|
||||
{% if fields.get('brand') or fields.get('model') %}
|
||||
<p class="sf-tp-product-meta">{% if fields.get('brand') %}<span>Brand · {{ fields.get('brand') }}</span>{% endif %}{% if fields.get('model') %}<span>Model · {{ fields.get('model') }}</span>{% endif %}</p>
|
||||
{% endif %}
|
||||
<div class="sf-tp-product-actions">
|
||||
<a class="sf-tp-btn" href="/contact/">Get a quote {{ icon('arrow-right') }}</a>
|
||||
<a class="sf-tp-btn sf-tp-btn--ghost" href="/products/">All products {{ icon('arrow-right') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section sf-tp-product-overview">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow">OVERVIEW</span>
|
||||
<h2 class="sf-tp-panel-title">Product details</h2>
|
||||
<div class="sf-tp-product-body" data-reveal>{{ body_copy(content, page) }}</div>
|
||||
</div>
|
||||
{# 选项卡详情(cms .product-tabbed):Tab 列表按数据动态生成——
|
||||
Product Details(正文)恒有;Specifications 仅 specs 有数据才显示。
|
||||
cms 的每产品自定义区块/Features 无平台数据源,不进 Tab。 #}
|
||||
{% set tp_spec_rows = specs | rejectattr('label', 'equalto', 'gallery') | list %}
|
||||
<div class="sf-tp-product-tabbed" data-sf-tp-tabbed>
|
||||
<nav class="sf-tp-product-tabs" aria-label="Product details">
|
||||
<div class="wrap sf-tp-tabs-row">
|
||||
<button class="sf-tp-tab active" data-panel="overview" role="tab" aria-selected="true" type="button">Product Details</button>
|
||||
{% if tp_spec_rows %}
|
||||
<button class="sf-tp-tab" data-panel="specifications" role="tab" aria-selected="false" type="button">Specifications</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
<section id="overview" class="sf-tp-detail-section sf-tp-panel active">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">Product details</h2>
|
||||
<div class="sf-tp-product-body">{{ body_copy(content, page) }}</div>
|
||||
</div>
|
||||
</section>
|
||||
{% if specs %}
|
||||
<section class="section section--soft sf-tp-product-specs">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow">SPECIFICATIONS</span>
|
||||
<h2 class="sf-tp-panel-title">Specifications</h2>
|
||||
<div class="sf-tp-table-scroll" data-reveal>
|
||||
<table class="sf-tp-spec-table">
|
||||
<tbody>
|
||||
{# theme_pro excludes management fields; 'gallery' is a media list, not a spec #}
|
||||
{% for row in specs if row.label != 'gallery' %}
|
||||
<tr><th scope="row">{{ row.label }}</th><td>{{ row.value }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% if tp_spec_rows %}
|
||||
<section id="specifications" class="sf-tp-detail-section sf-tp-detail-section--compact sf-tp-panel">
|
||||
<div class="wrap">
|
||||
<h2 class="sf-tp-panel-title">Specifications</h2>
|
||||
<div class="sf-tp-table-scroll">
|
||||
<table class="sf-tp-spec-table">
|
||||
<tbody>
|
||||
{# cms spec-table:每行两对键值(batch(2) 纵向配对) #}
|
||||
{% for row in tp_spec_rows | batch(2) %}
|
||||
<tr>{% for cell in row %}<th scope="row">{{ cell.label }}</th><td>{{ cell.value }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
<script>
|
||||
/* 详情选项卡(cms [slug].astro 尾部脚本移植):渐进增强——运行时给容器加
|
||||
sf-tp-tabs-enabled(CSS 只显示 active 面板并隐藏 panel-title);无 JS 时
|
||||
面板全部平铺可读。 */
|
||||
(function () {
|
||||
var tabbed = document.querySelector('[data-sf-tp-tabbed]');
|
||||
if (!tabbed) return;
|
||||
tabbed.classList.add('sf-tp-tabs-enabled');
|
||||
var tabs = Array.prototype.slice.call(tabbed.querySelectorAll('.sf-tp-tab'));
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
tabs.forEach(function (t) {
|
||||
var on = t === tab;
|
||||
t.classList.toggle('active', on);
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
Array.prototype.slice.call(tabbed.querySelectorAll('.sf-tp-panel')).forEach(function (p) {
|
||||
p.classList.toggle('active', p.id === tab.getAttribute('data-panel'));
|
||||
});
|
||||
tab.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% set tp_related = (groups['product'] + groups['vehicle']) | rejectattr('route', 'equalto', page.route) | list %}
|
||||
{% if tp_related %}
|
||||
<section class="section">
|
||||
<div class="wrap">
|
||||
<div class="sf-tp-section-head" data-reveal>
|
||||
<div>
|
||||
<span class="eyebrow">RELATED PRODUCTS</span>
|
||||
<h2>You may also need</h2>
|
||||
</div>
|
||||
<a class="sf-tp-more" href="/products/">Browse all products {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||||
</div>
|
||||
<div class="sf-tp-card-grid">
|
||||
{% for tp_item in tp_related[:4] %}
|
||||
<article class="sf-tp-card" data-reveal style="--reveal-i: {{ loop.index0 % 4 }}">
|
||||
<div class="sf-tp-card-media">{{ cover(tp_item, 'product') }}</div>
|
||||
<div class="sf-tp-card-body">
|
||||
<h3>{{ tp_item.title }}</h3>
|
||||
<p>{{ (tp_item.summary or 'Published specifications available on request.').split('.')[0] }}.</p>
|
||||
<a class="sf-tp-more" href="{{ safe_href(tp_item.route) }}">View details {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="sf-tp-section-head" data-reveal>
|
||||
<div>
|
||||
<span class="eyebrow">RELATED PRODUCTS</span>
|
||||
<h2>You may also need</h2>
|
||||
</div>
|
||||
<a class="sf-tp-more" href="/products/">Browse all products {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||||
</div>
|
||||
<div class="sf-tp-card-grid">
|
||||
{% for tp_item in tp_related[:4] %}
|
||||
<article class="sf-tp-card" data-reveal style="--reveal-i: {{ loop.index0 % 4 }}">
|
||||
<div class="sf-tp-card-media">{{ cover(tp_item, 'product') }}</div>
|
||||
<div class="sf-tp-card-body">
|
||||
<h3>{{ tp_item.title }}</h3>
|
||||
<p>{{ (tp_item.summary or 'Published specifications available on request.').split('.')[0] }}.</p>
|
||||
<a class="sf-tp-more" href="{{ safe_href(tp_item.route) }}">View details {{ icon('arrow-right', cls='sf-tp-icon--sm') }}</a>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="section section--soft sf-tp-product-quote">
|
||||
<div class="wrap sf-tp-quote-grid">
|
||||
<div class="sf-tp-quote-info" data-reveal>
|
||||
<span class="eyebrow">REQUEST A QUOTE</span>
|
||||
<h2>Inquire about this product</h2>
|
||||
<p>Send the specifications you need for <strong>{{ page.title }}</strong> and our trade desk replies with pricing, lead time and export packaging within one business day.</p>
|
||||
<div class="sf-tp-quote-list">
|
||||
{% if footer_email %}
|
||||
<div class="sf-tp-quote-item">
|
||||
<div class="icon-circle" aria-hidden="true">{{ icon('mail') }}</div>
|
||||
<div><strong>Email</strong><p><a href="mailto:{{ footer_email }}">{{ footer_email }}</a></p></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="sf-tp-quote-item">
|
||||
<div class="icon-circle" aria-hidden="true">{{ icon('check') }}</div>
|
||||
<div><strong>Direct sourcing desk</strong><p>One contact from inquiry to container loading.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sf-tp-quote-form" data-reveal style="--reveal-i: 1">
|
||||
{% if form_endpoint %}
|
||||
<h3>Get a quote for {{ page.title }}</h3>
|
||||
<form action="{{ form_endpoint }}" method="POST">
|
||||
<label>Name <input name="name" autocomplete="name" required maxlength="100"></label>
|
||||
<label>Email <input name="email" type="email" autocomplete="email" required maxlength="180"></label>
|
||||
<label>Message <textarea name="message" required minlength="6" maxlength="5000" placeholder="I would like a quotation for {{ page.title }}..."></textarea></label>
|
||||
<input name="website" tabindex="-1" autocomplete="off" class="pro-honeypot" aria-hidden="true">
|
||||
<button type="submit" class="sf-tp-btn">Submit enquiry {{ icon('arrow-right') }}</button>
|
||||
<p class="sf-tp-quote-note">Submissions are handled by the external form service configured by the site administrator.</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<h3>Get a quote for {{ page.title }}</h3>
|
||||
<p>Share your specifications and target market through the contact page; our trade desk replies within one business day.</p>
|
||||
<a class="sf-tp-btn" href="/contact/">Contact us {{ icon('arrow-right') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap sf-tp-quote-grid">
|
||||
<div class="sf-tp-quote-info" data-reveal>
|
||||
<span class="eyebrow">REQUEST A QUOTE</span>
|
||||
<h2>Inquire about this product</h2>
|
||||
<p>Send the specifications you need for <strong>{{ page.title }}</strong> and our trade desk replies with pricing, lead time and export packaging within one business day.</p>
|
||||
<div class="sf-tp-quote-list">
|
||||
{% if footer_email %}
|
||||
<div class="sf-tp-quote-item">
|
||||
<div class="icon-circle" aria-hidden="true">{{ icon('mail') }}</div>
|
||||
<div><strong>Email</strong><p><a href="mailto:{{ footer_email }}">{{ footer_email }}</a></p></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="sf-tp-quote-item">
|
||||
<div class="icon-circle" aria-hidden="true">{{ icon('check') }}</div>
|
||||
<div><strong>Direct sourcing desk</strong><p>One contact from inquiry to container loading.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sf-tp-quote-form" data-reveal style="--reveal-i: 1">
|
||||
{% if form_endpoint %}
|
||||
<h3>Get a quote for {{ page.title }}</h3>
|
||||
<form action="{{ form_endpoint }}" method="POST">
|
||||
<label>Name <input name="name" autocomplete="name" required maxlength="100"></label>
|
||||
<label>Email <input name="email" type="email" autocomplete="email" required maxlength="180"></label>
|
||||
<label>Message <textarea name="message" required minlength="6" maxlength="5000" placeholder="I would like a quotation for {{ page.title }}..."></textarea></label>
|
||||
<input name="website" tabindex="-1" autocomplete="off" class="pro-honeypot" aria-hidden="true">
|
||||
<button type="submit" class="sf-tp-btn">Submit enquiry {{ icon('arrow-right') }}</button>
|
||||
<p class="sf-tp-quote-note">Submissions are handled by the external form service configured by the site administrator.</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<h3>Get a quote for {{ page.title }}</h3>
|
||||
<p>Share your specifications and target market through the contact page; our trade desk replies within one business day.</p>
|
||||
<a class="sf-tp-btn" href="/contact/">Contact us {{ icon('arrow-right') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user