mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-12 16:01:56 +08:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user