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)}
` } 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, '"') }