import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
import { applyEngineSelection } from '../lib/engine-manager.js'
import { toast } from '../components/toast.js'
const PRIMARY_OPTIONS = [
{
id: 'openclaw',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw'],
targetRoute: '/setup',
},
{
id: 'hermes',
activeEngineId: 'hermes',
enabledEngineIds: ['hermes'],
targetRoute: '/h/setup',
},
]
const SECONDARY_OPTIONS = [
{
id: 'both',
activeEngineId: 'openclaw',
enabledEngineIds: ['openclaw', 'hermes'],
engineMode: 'both',
targetRoute: '/setup',
},
{
id: 'later',
activeEngineId: 'openclaw',
enabledEngineIds: [],
deferred: true,
targetRoute: '/engine-select',
},
]
const ICONS = {
openclaw: '',
hermes: '',
}
let _busy = false
let _revealEl = null
let _homeEl = null
let _animTimers = [] // 跟踪动画 setTimeout,cleanup 时清
export async function render() {
const page = document.createElement('div')
page.className = 'page engine-select-page es-monolith'
page.innerHTML = `
${esc(t('engine.choiceTopBanner'))}
CLAWPANEL
v—
${renderContent('openclaw')}
${renderContent('hermes')}
·
`
// 注入版本号(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 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'
? `${ICONS[id]}${esc(num)} · ${esc(cat)}`
: `${esc(cat)} · ${esc(num)}${ICONS[id]}`
return `
${productRow}
${esc(cap)}
${esc(tagline)}
${feats.map(f => `- ${esc(f)}
`).join('')}
`
}
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
})
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 后开始中心圆扩散
_animTimers.push(setTimeout(() => {
if (_revealEl) {
_revealEl.dataset.engine = engine
_revealEl.classList.add('es-reveal-active')
}
}, 600))
// 阶段 3: 1300ms 后保存选择 + 切换路由
_animTimers.push(setTimeout(async () => {
try {
await applyEngineSelection({
activeEngineId: option.activeEngineId,
enabledEngineIds: option.enabledEngineIds,
deferred: !!option.deferred,
choice: option.id,
engineMode: option.engineMode || '',
})
navigate(option.targetRoute)
// 给新页面一点渲染时间后淡出 reveal 层
_animTimers.push(setTimeout(() => {
if (_revealEl) {
_revealEl.classList.add('es-reveal-fadeout')
_animTimers.push(setTimeout(() => removeRevealNodes(), 600))
}
}, 280))
} catch (error) {
console.error('[engine-select] choose failed:', error)
toast(t('engine.choiceSaveFailed'), 'error')
// 失败回退:移除动画层 + 解除 busy
removeRevealNodes()
// stage 可能已不在 DOM(路由已切走)— 防御性访问
try { delete stage.dataset.expanding } catch (_) {}
_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 removeRevealNodes() {
if (_revealEl) { _revealEl.remove(); _revealEl = null }
if (_homeEl) { _homeEl.remove(); _homeEl = null }
_busy = false
}
export function cleanup() {
// 路由切走时清所有 setTimeout,避免动画后期调 navigate / mutate stale element
for (const id of _animTimers) {
try { clearTimeout(id) } catch (_) {}
}
_animTimers = []
// 不主动销毁 reveal 节点(动画完成后会自行淡出)— 但万一动画被中断,亦初始化允许下次 ensureRevealNodes 重新创建
_busy = false
}
function esc(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}