Files
test-repo/docs/.vitepress/theme/components/HomeFeatures.vue
T

858 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter, withBase, useData } from 'vitepress'
import GitHubStars from './GitHubStars.vue'
import VibeStories from './VibeStories.vue'
import { provide } from 'vue'
import { i18n } from './home/HomeI18n'
import { locales } from './home/HomeData'
import HomeStage1 from './home/HomeStage1.vue'
import HomeStage2 from './home/HomeStage2.vue'
import HomeStage3 from './home/HomeStage3.vue'
import HomeAppendix from './home/HomeAppendix.vue'
import HomeAppleFooter from './home/HomeAppleFooter.vue'
const router = useRouter()
const { site, page, lang } = useData()
const activeTab = ref('home')
const showLangMenu = ref(false)
const topPromoProgress = ref(1)
const topPromoDismissed = ref(false)
const topPromoIntroProgress = ref(0)
const topPromoColorProgress = ref(0)
let topPromoIntroRaf = 0
let topPromoColorRaf = 0
let topPromoColorTimer = 0
const WELCOME_SEEN_KEY = 'easy-vibe-welcome-seen'
const vibeStoriesSection = ref(null)
const t = computed(() => {
const code = lang.value ? lang.value.toLowerCase() : 'zh-cn'
const result = i18n[code] || i18n['en']
result._locale = code
return result
})
provide('t', t)
const isCjkLocale = computed(() => {
const code = lang.value ? lang.value.toLowerCase() : ''
if (['zh-cn', 'zh-tw', 'ja-jp', 'ko-kr'].includes(code)) {
return true
}
const path = router.route.path.toLowerCase()
return /^\/(zh-cn|zh-tw|ja-jp|ko-kr)\//.test(path)
})
const topPromo = computed(() => {
const code = lang.value ? lang.value.toLowerCase() : 'en'
if (code === 'zh-cn' || code === 'zh-tw') {
return {
text: '用 Easy-Vibe 构建你的第一个 AI 应用,最快当天可上线原型。',
cta: '开始学习 ',
link: '/zh-cn/stage-1/learning-map/'
}
}
return {
text: 'Build your first AI app with Easy-Vibe and ship a working prototype fast.',
cta: 'Start learning ',
link: '/en/stage-1/learning-map/'
}
})
const toggleLangMenu = () => {
showLangMenu.value = !showLangMenu.value
}
const updateHash = (id) => {
const targetHash = id === 'home' ? '#home' : `#${id}`
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`
const nextUrl = `${window.location.pathname}${window.location.search}${targetHash}`
if (currentUrl !== nextUrl) {
window.history.replaceState(null, '', nextUrl)
}
}
const syncTopPromoWithHash = () => {
const rawHash = window.location.hash.replace(/^#/, '')
const targetId = rawHash || 'home'
if (targetId === 'home') {
topPromoDismissed.value = false
topPromoProgress.value = 1
return
}
topPromoDismissed.value = true
topPromoProgress.value = 0
}
const changeLang = (targetLocale) => {
const currentPath = router.route.path
const currentLocale = locales.find((l) =>
currentPath.startsWith(`/${l.code}/`)
)
let newPath
if (currentLocale) {
newPath = currentPath.replace(
`/${currentLocale.code}/`,
`/${targetLocale}/`
)
} else {
newPath = `/${targetLocale}/`
}
const hash = window.location.hash || ''
router.go(withBase(`${newPath}${hash}`))
showLangMenu.value = false
}
const scrollTo = (id) => {
if (id === 'home') {
window.scrollTo({ top: 0, behavior: 'smooth' })
activeTab.value = 'home'
updateHash('home')
syncTopPromoWithHash()
updateTopPromoVisibility()
return
}
const el = document.getElementById(id)
if (el) {
const navHeight = 48
const elementPosition = el.getBoundingClientRect().top + window.pageYOffset
const extraOffset = id === 'vibe-stories' ? 20 : 40
const offset = elementPosition - navHeight - extraOffset
window.scrollTo({ top: offset, behavior: 'smooth' })
activeTab.value = id
updateHash(id)
syncTopPromoWithHash()
}
}
const scrollToHashTarget = (behavior = 'auto') => {
const rawHash = window.location.hash.replace(/^#/, '')
const targetId = rawHash || 'home'
if (targetId === 'home') {
window.scrollTo({ top: 0, behavior })
activeTab.value = 'home'
syncTopPromoWithHash()
updateTopPromoVisibility()
return
}
const el = document.getElementById(targetId)
if (el) {
const navHeight = 48
const elementPosition = el.getBoundingClientRect().top + window.pageYOffset
const extraOffset = targetId === 'vibe-stories' ? 20 : 40
const offset = elementPosition - navHeight - extraOffset
window.scrollTo({ top: offset, behavior })
activeTab.value = targetId
syncTopPromoWithHash()
}
}
const closeLangMenu = (e) => {
if (!e.target.closest('.lang-switch-wrapper')) {
showLangMenu.value = false
}
}
const updateTopPromoVisibility = () => {
if (topPromoDismissed.value) {
topPromoProgress.value = 0
return
}
if (!vibeStoriesSection.value) {
topPromoProgress.value = 1
return
}
const navHeight = 44
const sectionTop =
vibeStoriesSection.value.getBoundingClientRect().top + window.pageYOffset
const endY = sectionTop - navHeight
const startY = endY - 96
const scrollY = window.pageYOffset
if (scrollY <= startY) {
topPromoProgress.value = 1
return
}
if (scrollY >= endY) {
topPromoProgress.value = 0
topPromoDismissed.value = true
return
}
topPromoProgress.value = (endY - scrollY) / (endY - startY)
}
const topPromoStyle = computed(() => {
const scrollProgress = topPromoProgress.value
const introProgress = topPromoIntroProgress.value
const colorProgress = topPromoColorProgress.value
const progress = scrollProgress * introProgress
const scrollOffset = -100 * (1 - scrollProgress)
const startTextColor = { r: 255, g: 255, b: 255 }
const endTextColor = { r: 29, g: 29, b: 31 }
const startBgColor = { r: 0, g: 113, b: 227 }
const endBgColor = { r: 245, g: 245, b: 247 }
const startLinkColor = { r: 255, g: 255, b: 255 }
const endLinkColor = { r: 0, g: 102, b: 204 }
const textColor = `rgb(${Math.round(startTextColor.r + (endTextColor.r - startTextColor.r) * colorProgress)}, ${Math.round(startTextColor.g + (endTextColor.g - startTextColor.g) * colorProgress)}, ${Math.round(startTextColor.b + (endTextColor.b - startTextColor.b) * colorProgress)})`
const bgColor = `rgb(${Math.round(startBgColor.r + (endBgColor.r - startBgColor.r) * colorProgress)}, ${Math.round(startBgColor.g + (endBgColor.g - startBgColor.g) * colorProgress)}, ${Math.round(startBgColor.b + (endBgColor.b - startBgColor.b) * colorProgress)})`
const linkColor = `rgb(${Math.round(startLinkColor.r + (endLinkColor.r - startLinkColor.r) * colorProgress)}, ${Math.round(startLinkColor.g + (endLinkColor.g - startLinkColor.g) * colorProgress)}, ${Math.round(startLinkColor.b + (endLinkColor.b - startLinkColor.b) * colorProgress)})`
return {
opacity: progress,
transform: `translateY(${scrollOffset}%)`,
maxHeight: `${30 * progress}px`,
backgroundColor: bgColor,
color: textColor,
'--top-promo-link-color': linkColor,
pointerEvents: progress < 0.02 ? 'none' : 'auto'
}
})
const resolveFooterHref = (link) => {
if (link.startsWith('http://') || link.startsWith('https://')) {
return link
}
return withBase(link)
}
onMounted(() => {
const introDuration = 1800
const colorDelay = 500
const colorDuration = 1800
const introStart = performance.now()
const stepTopPromoIntro = (now) => {
const raw = Math.min(1, (now - introStart) / introDuration)
const eased = 1 - Math.pow(1 - raw, 3)
topPromoIntroProgress.value = eased
if (raw < 1) {
topPromoIntroRaf = window.requestAnimationFrame(stepTopPromoIntro)
return
}
topPromoColorTimer = window.setTimeout(() => {
const colorStart = performance.now()
const stepTopPromoColor = (time) => {
const colorRaw = Math.min(1, (time - colorStart) / colorDuration)
const colorEased = 1 - Math.pow(1 - colorRaw, 3)
topPromoColorProgress.value = colorEased
if (colorRaw < 1) {
topPromoColorRaf = window.requestAnimationFrame(stepTopPromoColor)
}
}
topPromoColorRaf = window.requestAnimationFrame(stepTopPromoColor)
}, colorDelay)
}
topPromoIntroRaf = window.requestAnimationFrame(stepTopPromoIntro)
const currentPath = window.location.pathname
const basePath = site.value.base || '/'
const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`
const normalizedPath = currentPath.endsWith('/')
? currentPath
: `${currentPath}/`
const localeHomeSuffixes = [
'/zh-cn/',
'/en/',
'/zh-tw/',
'/ja-jp/',
'/ko-kr/',
'/es-es/',
'/fr-fr/',
'/de-de/',
'/ar-sa/',
'/vi-vn/'
]
const isLocaleHome = localeHomeSuffixes.some(
(suffix) =>
currentPath.endsWith(suffix) ||
currentPath.endsWith(`${suffix}index.html`)
)
const isRootHome =
normalizedPath === normalizedBase ||
currentPath === `${normalizedBase}index.html`
if (isRootHome && !isLocaleHome) {
const hasSeenWelcome = window.localStorage.getItem(WELCOME_SEEN_KEY) === '1'
if (!hasSeenWelcome) {
router.go(withBase(`/welcome/?next=${encodeURIComponent(currentPath)}`))
return
}
}
document.addEventListener('click', closeLangMenu)
syncTopPromoWithHash()
window.setTimeout(() => {
scrollToHashTarget('auto')
}, 0)
updateTopPromoVisibility()
window.addEventListener('scroll', updateTopPromoVisibility, { passive: true })
window.addEventListener('resize', updateTopPromoVisibility)
window.addEventListener('hashchange', scrollToHashTarget)
})
onUnmounted(() => {
if (topPromoIntroRaf) {
window.cancelAnimationFrame(topPromoIntroRaf)
topPromoIntroRaf = 0
}
if (topPromoColorRaf) {
window.cancelAnimationFrame(topPromoColorRaf)
topPromoColorRaf = 0
}
if (topPromoColorTimer) {
window.clearTimeout(topPromoColorTimer)
topPromoColorTimer = 0
}
document.removeEventListener('click', closeLangMenu)
window.removeEventListener('scroll', updateTopPromoVisibility)
window.removeEventListener('resize', updateTopPromoVisibility)
window.removeEventListener('hashchange', scrollToHashTarget)
})
</script>
<template>
<div class="apple-container">
<nav class="sticky-nav glass">
<div class="nav-content">
<div class="nav-cluster">
<div
class="nav-title"
:aria-label="t.nav.title"
>
<img
class="nav-title-logo no-viewer"
:src="withBase('/assets/easy-vibe-logo-hd.svg')"
:alt="t.nav.title"
width="64"
height="30"
draggable="false"
>
</div>
<div class="nav-links">
<button
:class="{ active: activeTab === 'home' }"
class="nav-link-item"
@click="scrollTo('home')"
>
{{ t.nav.home }}
</button>
<button
:class="{ active: activeTab === 'vibe-stories' }"
class="nav-link-item"
@click="scrollTo('vibe-stories')"
>
{{ t.nav.stories || 'Vibe 故事' }}
</button>
<button
:class="{ active: activeTab === 'pm' }"
class="nav-link-item"
@click="scrollTo('pm')"
>
{{ t.nav.pm }}
</button>
<button
:class="{ active: activeTab === 'junior' }"
class="nav-link-item"
@click="scrollTo('junior')"
>
{{ t.nav.junior }}
</button>
<button
:class="{ active: activeTab === 'senior' }"
class="nav-link-item"
@click="scrollTo('senior')"
>
{{ t.nav.senior }}
</button>
<button
:class="{ active: activeTab === 'appendix' }"
class="nav-link-item"
@click="scrollTo('appendix')"
>
{{ t.nav.appendix }}
</button>
</div>
<div class="nav-action">
<div class="nav-icons">
<div class="lang-switch-wrapper">
<button
type="button"
class="button"
aria-haspopup="true"
:aria-expanded="showLangMenu"
aria-label="Change language"
@click.stop="toggleLangMenu"
>
<span class="text">
<span class="vpi-languages option-icon" />
<span class="vpi-chevron-down text-icon" />
</span>
</button>
<div
v-if="showLangMenu"
class="lang-dropdown glass"
>
<button
v-for="locale in locales"
:key="locale.code"
class="lang-item"
@click="changeLang(locale.code)"
>
{{ locale.text }}
</button>
</div>
</div>
<GitHubStars class="nav-github-stars" />
</div>
<a
class="buy-btn"
:href="withBase(t.stage1.cards[0].link)"
>{{ t.footer.btn }}</a>
</div>
</div>
</div>
<div
class="nav-promo"
:style="topPromoStyle"
>
<span>{{ topPromo.text }}</span>
<a :href="resolveFooterHref(topPromo.link)">{{ topPromo.cta }}</a>
</div>
</nav>
<div
id="home"
style="height: 0"
/>
<section
id="vibe-stories"
ref="vibeStoriesSection"
class="section-container"
>
<VibeStories />
</section>
<div class="section-band section-band-learning">
<HomeStage1 />
<HomeStage2 />
</div>
<HomeStage3 />
<HomeAppendix />
<HomeAppleFooter :is-cjk-locale="isCjkLocale" />
</div>
</template>
<style scoped>
.apple-container {
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
'Helvetica Neue', sans-serif;
color: var(--vp-c-text-1);
background: transparent;
}
#vibe-stories,
#vibe-stories:focus,
#vibe-stories:focus-visible,
#vibe-stories:target {
outline: none !important;
box-shadow: none !important;
}
a {
text-decoration: none;
color: inherit;
}
:is(.buy-btn) {
border-bottom: none !important;
outline: none !important;
-webkit-tap-highlight-color: transparent;
}
:is(.buy-btn):is(:hover, :focus, :focus-visible, :active) {
border-bottom-color: transparent !important;
text-decoration: none !important;
outline: none !important;
}
.highlight {
color: var(--vp-c-text-2);
}
.sticky-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
border-bottom: 1px solid #d2d2d7;
transition: all 0.3s ease;
background: rgba(245, 245, 247, 0.82);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
:root.dark .sticky-nav {
background: rgba(18, 18, 20, 0.8);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.nav-content {
max-width: 1280px;
margin: 0 auto;
padding: 0 28px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 2;
}
.nav-cluster {
display: flex;
align-items: center;
gap: 20px;
max-width: 100%;
}
.nav-title {
flex-shrink: 0;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: default;
display: inline-flex;
align-items: center;
}
.nav-title-logo {
display: block;
max-width: 64px !important;
max-height: 30px !important;
height: 30px !important;
width: 64px !important;
min-width: 64px;
min-height: 30px;
object-fit: contain;
flex: 0 0 auto;
filter: grayscale(1) brightness(0.28) contrast(1.05);
}
.nav-links {
display: flex;
gap: 20px;
align-items: center;
margin: 0;
white-space: nowrap;
}
.nav-links button,
.nav-link-item {
background: none;
border: none;
font-size: 12px;
color: var(--vp-c-text-1) !important;
cursor: pointer;
transition: opacity 0.2s;
padding: 0;
margin: 0;
line-height: 1;
font-weight: 400;
opacity: 0.76;
text-decoration: none;
}
.nav-links button:hover,
.nav-links button.active,
.nav-link-item:hover {
color: var(--vp-c-text-1) !important;
opacity: 1;
}
.nav-action {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.nav-icons {
display: flex;
gap: 10px;
align-items: center;
}
:deep(.nav-github-stars) {
display: flex;
align-items: center;
}
:deep(.nav-github-stars .github-stars-link) {
color: var(--vp-c-text-1) !important;
display: flex;
align-items: center;
gap: 4px;
text-decoration: none;
}
:deep(.nav-github-stars .github-stars-link:hover) {
opacity: 0.7;
}
:deep(.nav-github-stars .github-stars-wrapper) {
padding-left: 0 !important;
}
.nav-promo {
height: 30px;
max-height: 30px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 13px;
color: #1d1d1f;
padding: 0 16px;
overflow: hidden;
transform-origin: top center;
position: relative;
z-index: 1;
will-change: transform, opacity, max-height, background-color, color;
transition:
transform 0.16s ease-out,
opacity 0.16s ease-out,
max-height 0.16s ease-out,
background-color 0.22s ease-out,
color 0.22s ease-out;
}
.nav-promo a {
color: var(--top-promo-link-color, #0066cc);
text-decoration: none;
transition: color 0.25s ease-out;
}
.button {
background: none;
border: none;
padding: 0;
color: var(--vp-c-text-1) !important;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.2s;
}
.button:hover {
opacity: 0.7;
}
.button .text {
display: flex;
align-items: center;
gap: 2px;
}
.button .option-icon {
width: 20px;
height: 20px;
color: var(--vp-c-text-1) !important;
}
.button .text-icon {
width: 14px;
height: 14px;
color: var(--vp-c-text-1) !important;
}
.lang-switch-wrapper {
position: relative;
display: flex;
align-items: center;
}
.lang-dropdown {
position: absolute;
top: 100%;
right: -10px;
margin-top: 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
padding: 6px;
min-width: 140px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.14);
display: flex;
flex-direction: column;
gap: 2px;
max-height: 300px;
overflow-y: auto;
z-index: 20;
}
.lang-item {
text-align: left;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
color: var(--vp-c-text-1);
transition: background 0.2s;
background: transparent;
border: none;
cursor: pointer;
white-space: nowrap;
}
.lang-item:hover {
background: var(--vp-c-bg-soft);
}
.buy-btn {
background: #0071e3;
color: #fff !important;
padding: 7px 16px;
border-radius: 980px;
font-size: 13px;
font-weight: 500;
line-height: 1;
transition: all 0.2s ease;
}
.buy-btn:hover {
background: #0077ed;
transform: scale(1.02);
}
.buy-btn.large {
padding: 12px 24px;
font-size: 15px;
margin-top: 20px;
display: inline-block;
}
.section-container {
max-width: 1280px;
margin: 0 auto 96px;
padding: 0 40px;
}
.section-band-learning {
width: 100vw;
max-width: none;
margin: 0 calc(50% - 50vw) 96px;
background: #f5f5f7;
border-radius: 0;
padding-top: 64px;
padding-bottom: 64px;
padding-left: max(40px, calc((100vw - 1280px) / 2 + 40px));
padding-right: max(40px, calc((100vw - 1280px) / 2 + 40px));
}
.section-band-learning .section-container {
max-width: 1280px;
margin: 0 auto;
padding: 0;
}
.section-band-learning .section-junior {
margin-top: 72px;
}
.dark .section-band-learning {
background: rgba(255, 255, 255, 0.03);
}
.section-header {
margin-bottom: 44px;
}
.section-category {
font-size: 24px;
font-weight: 700;
margin-bottom: 14px;
border: none;
padding: 0;
color: #1d1d1f;
letter-spacing: -0.024em;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC',
sans-serif;
}
.section-headline {
font-size: 64px;
line-height: 1.08;
font-weight: 700;
letter-spacing: -0.034em;
margin-bottom: 12px;
color: #1d1d1f;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC',
sans-serif;
}
.section-sub {
font-size: 21px;
line-height: 1.4;
font-weight: 400;
letter-spacing: -0.01em;
color: #6e6e73;
max-width: 760px;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
sans-serif;
}
.dark .section-category,
.dark .section-headline {
color: var(--vp-c-text-1);
}
.dark .section-sub {
color: var(--vp-c-text-2);
}
@media (max-width: 768px) {
.section-headline {
font-size: 42px;
}
.nav-links {
display: none;
}
.nav-promo {
font-size: 12px;
height: 28px;
justify-content: flex-start;
overflow-x: auto;
white-space: nowrap;
}
.section-band-learning {
margin-bottom: 96px;
padding-top: 42px;
padding-bottom: 42px;
padding-left: 24px;
padding-right: 24px;
}
.section-band-learning .section-junior {
margin-top: 56px;
}
}
</style>
<style>
.VPHome {
padding-top: 84px !important;
}
</style>