feat(engine-select): Monolith 对角线全屏选择屏

替换原卡片网格为「左上 OpenClaw(石墨黑)vs 右下 Hermes(象牙白)」对角线全屏设计。
启动屏 / 引擎切换时给用户一个有冲击力的「选择时刻」。

## 核心设计
- position: fixed 跳出 #content 范围,覆盖整个 viewport(含 sidebar)
- 双三角形 clip-path: polygon (0 0, 0 100%, 100% 0) / (100% 0, 100% 100%, 0 100%)
- 内容用 absolute 定位到三角形质心(左上 11%/6.5% / 右下 11%/6.5%),永不重叠
- 微妙的 60px 网格纹 + 角落极光(紫蓝 / 橙金)+ 极细中线分割
- clamp(80px, 13vw, 200px) 巨字标题 + 序号 + Logo + tagline + 特性列表 + CTA

## 交互
- hover 联动:用 [data-hover] attribute 替代 :has(),兼容旧 WebKit
  - 鼠标悬停一侧 → 该侧亮起 + 内容平移 + CTA 反白;另一侧变暗 + 内容模糊缩小
- 点击三角形 → 三角形 clip-path 扩满(0.8s)→ 中心圆扩散(0.9s)→ 进入主页
- reveal 节点 attach 到 body,跨路由切换存活,新页面渲染后再淡出

## Both / Later 处理
- 两个次级选项保留,做成底部居中的玻璃 pill 链接(不抢戏)
- 不走对角线扩散动画,点击后直接 applyEngineSelection + navigate

## 兼容性
- prefers-reduced-motion: reduce → 关闭所有动画
- 移动端响应式:< 760px 调整字号 / 边距 / 角标
- 用 Vite define 注入的 __APP_VERSION__ 显示版本号(与 main.js / sidebar.js 一致)

## i18n
- engine.choiceTopBanner / choiceCtaEnter
- choiceOpenclaw{Tagline,Feat1,Feat2,Feat3,Category}
- choiceHermes{Tagline,Feat1,Feat2,Feat3,Category}
- choiceSecondary{Both,Later}
- 三语完整(zh-CN / en / zh-TW)

## 抽卡 prototype
保留 docs/engine-select-mockups/ 下的 V2 4 张设计 + 索引页(v2-monolith.html
即本次接入的最终版本)。
This commit is contained in:
晴天
2026-05-14 06:22:32 +08:00
parent cc19a07999
commit 6a0d87479c
12 changed files with 3330 additions and 223 deletions

View File

@@ -3,27 +3,24 @@ import { t } from '../lib/i18n.js'
import { applyEngineSelection } from '../lib/engine-manager.js'
import { toast } from '../components/toast.js'
const OPTIONS = [
const PRIMARY_OPTIONS = [
{
id: 'openclaw',
key: 'Openclaw',
icon: 'layers',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw'],
targetRoute: '/setup',
},
{
id: 'hermes',
key: 'Hermes',
icon: 'bolt',
activeEngineId: 'hermes',
enabledEngineIds: ['hermes'],
targetRoute: '/h/setup',
},
]
const SECONDARY_OPTIONS = [
{
id: 'both',
key: 'Both',
icon: 'spark',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw', 'hermes'],
engineMode: 'both',
@@ -31,8 +28,6 @@ const OPTIONS = [
},
{
id: 'later',
key: 'Later',
icon: 'clock',
activeEngineId: 'openclaw',
enabledEngineIds: [],
deferred: true,
@@ -41,83 +36,212 @@ const OPTIONS = [
]
const ICONS = {
layers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
bolt: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 2l1.7 6.3L20 10l-6.3 1.7L12 18l-1.7-6.3L4 10l6.3-1.7L12 2z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8L19 15z"/></svg>',
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
openclaw: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>',
hermes: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
}
let _busy = false
let _revealEl = null
let _homeEl = null
export async function render() {
const page = document.createElement('div')
page.className = 'page engine-select-page'
page.className = 'page engine-select-page es-monolith'
page.innerHTML = `
<section class="engine-select-hero">
<div class="engine-select-kicker">${esc(t('engine.choiceKicker'))}</div>
<h1>${esc(t('engine.choiceTitle'))}</h1>
<p>${esc(t('engine.choiceSubtitle'))}</p>
</section>
<section class="engine-choice-grid">
${OPTIONS.map(renderOption).join('')}
</section>
<section class="engine-choice-note">
<div class="engine-choice-note-title">${esc(t('engine.choiceNoteTitle'))}</div>
<div>${esc(t('engine.choiceNoteDesc'))}</div>
</section>
<div class="es-stage">
<div class="es-panel es-panel-openclaw" data-engine="openclaw">
<div class="es-glow es-glow-openclaw"></div>
</div>
<div class="es-panel es-panel-hermes" data-engine="hermes">
<div class="es-glow es-glow-hermes"></div>
</div>
<div class="es-divider"></div>
<div class="es-top-banner">${esc(t('engine.choiceTopBanner'))}</div>
<div class="es-corner-mark es-corner-tl">CLAWPANEL</div>
<div class="es-corner-mark es-corner-br" data-version-tag>v—</div>
${renderContent('openclaw')}
${renderContent('hermes')}
<div class="es-secondary">
<button type="button" class="es-secondary-link" data-secondary="both">${esc(t('engine.choiceSecondaryBoth'))}</button>
<span class="es-secondary-sep" aria-hidden="true">·</span>
<button type="button" class="es-secondary-link" data-secondary="later">${esc(t('engine.choiceSecondaryLater'))}</button>
</div>
</div>
`
page.addEventListener('click', async (event) => {
const card = event.target.closest('.engine-choice-card')
if (!card) return
const option = OPTIONS.find(item => item.id === card.dataset.choice)
if (!option || card.classList.contains('loading')) return
await chooseOption(page, card, option)
})
// 注入版本号package.json 同步Vite define 注入)
try {
const v = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : ''
const tag = page.querySelector('[data-version-tag]')
if (tag && v) tag.textContent = `v${v}`
} catch (_) {}
bindHover(page)
bindClick(page)
return page
}
function renderOption(option) {
const badge = t(`engine.choice${option.key}Badge`)
function renderContent(id) {
const num = id === 'openclaw' ? '01' : '02'
const cap = id === 'openclaw' ? 'OpenClaw' : 'Hermes'
const cat = id === 'openclaw' ? t('engine.choiceOpenclawCategory') : t('engine.choiceHermesCategory')
const tagline = id === 'openclaw' ? t('engine.choiceOpenclawTagline') : t('engine.choiceHermesTagline')
const feats = id === 'openclaw'
? [t('engine.choiceOpenclawFeat1'), t('engine.choiceOpenclawFeat2'), t('engine.choiceOpenclawFeat3')]
: [t('engine.choiceHermesFeat1'), t('engine.choiceHermesFeat2'), t('engine.choiceHermesFeat3')]
const cta = `${t('engine.choiceCtaEnter')} ${cap}`
// OpenClaw左上序号在前 / Hermes右下序号在后
const productRow = id === 'openclaw'
? `<span class="es-product-icon">${ICONS[id]}</span><span class="es-product-tag">${esc(num)} · ${esc(cat)}</span>`
: `<span class="es-product-tag">${esc(cat)} · ${esc(num)}</span><span class="es-product-icon">${ICONS[id]}</span>`
return `
<button class="engine-choice-card" data-choice="${option.id}">
<span class="engine-choice-icon">${ICONS[option.icon] || ''}</span>
<span class="engine-choice-content">
<span class="engine-choice-title-row">
<span class="engine-choice-title">${esc(t(`engine.choice${option.key}Title`))}</span>
${badge && badge !== `engine.choice${option.key}Badge` ? `<span class="engine-choice-badge">${esc(badge)}</span>` : ''}
</span>
<span class="engine-choice-desc">${esc(t(`engine.choice${option.key}Desc`))}</span>
<span class="engine-choice-meta">${esc(t(`engine.choice${option.key}Meta`))}</span>
</span>
<span class="engine-choice-arrow">→</span>
</button>
<div class="es-content es-content-${id}" data-engine-content="${id}">
<div class="es-product-row">${productRow}</div>
<div class="es-title">${esc(cap)}</div>
<div class="es-tagline">${esc(tagline)}</div>
<ul class="es-feature-list">
${feats.map(f => `<li>${esc(f)}</li>`).join('')}
</ul>
<button type="button" class="es-cta" data-engine-cta="${id}" tabindex="-1">
${id === 'openclaw' ? `<span>${esc(cta)}</span><span class="es-cta-arrow">→</span>` : `<span class="es-cta-arrow">→</span><span>${esc(cta)}</span>`}
</button>
</div>
`
}
async function chooseOption(page, card, option) {
setBusy(page, card, true)
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
function bindHover(page) {
// 用 attribute 替代 :has() — 兼容性更好(旧 WebKit / Linux WebKitGTK
const stage = page.querySelector('.es-stage')
page.querySelectorAll('.es-panel').forEach(panel => {
const engine = panel.dataset.engine
panel.addEventListener('mouseenter', () => {
if (_busy) return
stage.dataset.hover = engine
})
toast(t('engine.choiceSaved'), 'success')
navigate(option.targetRoute)
} catch (error) {
console.error('[engine-select] choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
setBusy(page, card, false)
panel.addEventListener('mouseleave', () => {
if (_busy) return
delete stage.dataset.hover
})
})
}
function bindClick(page) {
const stage = page.querySelector('.es-stage')
// 主区:点击三角形选引擎
stage.addEventListener('click', (event) => {
if (_busy) return
const panel = event.target.closest('.es-panel')
if (!panel) return
const engine = panel.dataset.engine
const option = PRIMARY_OPTIONS.find(o => o.id === engine)
if (option) chooseWithAnimation(page, panel, option, engine)
})
// 次级链接:两个都要 / 稍后再说(无对角线动画,直接走选择)
page.querySelectorAll('[data-secondary]').forEach(btn => {
btn.addEventListener('click', async (event) => {
event.stopPropagation()
if (_busy) return
const id = btn.dataset.secondary
const option = SECONDARY_OPTIONS.find(o => o.id === id)
if (!option) return
_busy = true
btn.classList.add('loading')
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
})
toast(t('engine.choiceSaved'), 'success')
navigate(option.targetRoute)
} catch (error) {
console.error('[engine-select] secondary choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
_busy = false
btn.classList.remove('loading')
}
})
})
}
async function chooseWithAnimation(page, panel, option, engine) {
_busy = true
const stage = page.querySelector('.es-stage')
delete stage.dataset.hover
stage.dataset.expanding = engine
// 先把 reveal / home mock 节点 attach 到 body — 路由切换时它们不会被销毁
ensureRevealNodes()
// 阶段 1: 三角形扩满CSS 通过 [data-expanding] 触发 clip-path 变化)
// 阶段 2: 600ms 后开始中心圆扩散
setTimeout(() => {
_revealEl.dataset.engine = engine
_revealEl.classList.add('es-reveal-active')
}, 600)
// 阶段 3: 1300ms 后保存选择 + 切换路由
setTimeout(async () => {
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
})
navigate(option.targetRoute)
// 给新页面一点渲染时间后淡出 reveal 层
setTimeout(() => {
if (_revealEl) {
_revealEl.classList.add('es-reveal-fadeout')
setTimeout(() => removeRevealNodes(), 600)
}
}, 280)
} catch (error) {
console.error('[engine-select] choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
// 失败回退:移除动画层 + 解除 busy
removeRevealNodes()
delete stage.dataset.expanding
_busy = false
}
}, 1300)
}
function ensureRevealNodes() {
if (!_revealEl) {
_revealEl = document.createElement('div')
_revealEl.className = 'es-reveal'
document.body.appendChild(_revealEl)
}
if (!_homeEl) {
_homeEl = document.createElement('div')
_homeEl.className = 'es-reveal-home'
document.body.appendChild(_homeEl)
}
}
function setBusy(page, activeCard, busy) {
page.querySelectorAll('.engine-choice-card').forEach(card => {
card.disabled = busy
card.classList.toggle('loading', busy && card === activeCard)
})
function removeRevealNodes() {
if (_revealEl) { _revealEl.remove(); _revealEl = null }
if (_homeEl) { _homeEl.remove(); _homeEl = null }
_busy = false
}
export function cleanup() {
// 路由切走时不主动销毁 reveal 节点(动画完成后会自行淡出)
// 这里仅重置 busy防卡死
_busy = false
}
function esc(value) {