update:打包上传&架构修改

This commit is contained in:
ltx 2026-07-27 16:44:06 +08:00
parent 1fbbb12f6a
commit 201c85b1d9
18 changed files with 1171 additions and 129 deletions

32
OSS-DEPLOY.md Normal file
View File

@ -0,0 +1,32 @@
# 阿里云 OSS 发布
在项目根目录打开 PowerShell,执行:
```powershell
npm run deploy:oss
```
脚本会自动完成以下操作:
1. 使用当前时间生成 `static/wwwweb/yyyyMMdd-HHmmss/` 目录。
2. 执行 Vue/Vite 生产构建。
3. 改写并检查 OSS 静态资源地址,阻止重复 `https://...https://...` 地址上传。
4. 上传 JS、CSS、图片等静态资源,并排除 `index.html`。
5. 验证线上 JS、CSS、Logo 可访问,同时确认 OSS 中不存在 `index.html`。
构建完成后,将终端输出的 `Server index` 文件部署到 `www.yuedongsports.com` 服务器。
## 前置配置
- AccessKey 配置文件:`C:\Users\当前用户\.ossutilconfig`
- ossutil 默认位置:`.tools\ossutil-2.3.0-windows-amd64\ossutil.exe`
如果路径不同,可以直接调用脚本并传入参数:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy-oss.ps1 `
-ConfigFile "D:\secure\.ossutilconfig" `
-OssutilPath "D:\tools\ossutil.exe"
```
仅在网络环境无法执行公网校验时,可增加 `-SkipRemoteVerify`;上传和本地路径检查仍会正常执行。

View File

@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"rewrite:oss": "node scripts/rewrite-oss-paths.mjs",
"deploy:oss": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-oss.ps1",
"preview": "vite preview"
},
"dependencies": {

137
scripts/deploy-oss.ps1 Normal file
View File

@ -0,0 +1,137 @@
[CmdletBinding()]
param(
[string]$Bucket = 'yuedong-wechatapplet',
[string]$Prefix = 'static/wwwweb',
[string]$PublicOrigin = 'https://yuedong-wechatapplet.oss-cn-shanghai.aliyuncs.com',
[string]$ConfigFile = (Join-Path $HOME '.ossutilconfig'),
[string]$OssutilPath = '',
[switch]$SkipRemoteVerify
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$DistDir = Join-Path $ProjectRoot 'dist'
$DefaultOssutil = Join-Path $ProjectRoot '.tools\ossutil-2.3.0-windows-amd64\ossutil.exe'
if (-not $OssutilPath) {
$OssutilPath = $DefaultOssutil
}
if (-not (Test-Path -LiteralPath $OssutilPath -PathType Leaf)) {
throw "ossutil not found: $OssutilPath"
}
if (-not (Test-Path -LiteralPath $ConfigFile -PathType Leaf)) {
throw "OSS config not found: $ConfigFile"
}
if (-not (Get-Command 'npm.cmd' -ErrorAction SilentlyContinue)) {
throw 'npm.cmd was not found. Install Node.js and reopen PowerShell.'
}
$Timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$CleanOrigin = $PublicOrigin.TrimEnd('/')
$CleanPrefix = $Prefix.Trim('/')
$PublicBase = "$CleanOrigin/$CleanPrefix/$Timestamp/"
$OssTarget = "oss://$Bucket/$CleanPrefix/$Timestamp/"
Push-Location $ProjectRoot
try {
Write-Host "[1/5] Building production files" -ForegroundColor Cyan
& npm.cmd run build -- "--base=$PublicBase"
if ($LASTEXITCODE -ne 0) {
throw "Vite build failed with exit code $LASTEXITCODE"
}
Write-Host "[2/5] Rewriting and validating OSS asset paths" -ForegroundColor Cyan
& npm.cmd run rewrite:oss -- $PublicBase
if ($LASTEXITCODE -ne 0) {
throw "OSS path validation failed with exit code $LASTEXITCODE"
}
$IndexPath = Join-Path $DistDir 'index.html'
$IndexContent = [System.IO.File]::ReadAllText($IndexPath)
$DuplicatePrefix = $PublicBase.TrimEnd('/') + $PublicBase
if ($IndexContent.Contains($DuplicatePrefix)) {
throw 'The generated index.html contains a duplicated OSS prefix.'
}
if (-not $IndexContent.Contains($PublicBase)) {
throw 'The generated index.html does not reference the new OSS directory.'
}
$Files = Get-ChildItem -LiteralPath $DistDir -Recurse -File
$UploadFiles = @($Files | Where-Object { $_.Name -ne 'index.html' })
$UploadBytes = ($UploadFiles | Measure-Object Length -Sum).Sum
Write-Host "[3/5] Uploading $($UploadFiles.Count) static files" -ForegroundColor Cyan
Write-Host " Target: $OssTarget"
& $OssutilPath -c $ConfigFile cp "$DistDir\" $OssTarget -r --exclude 'index.html' -f -q --no-progress
if ($LASTEXITCODE -ne 0) {
throw "OSS upload failed with exit code $LASTEXITCODE"
}
Write-Host '[4/5] Confirming index.html was excluded' -ForegroundColor Cyan
$RemoteIndexUrl = "${PublicBase}index.html?verify=$Timestamp"
try {
$RemoteIndexResponse = Invoke-WebRequest -Uri $RemoteIndexUrl -Method Head -UseBasicParsing
if ($RemoteIndexResponse.StatusCode -eq 200) {
throw 'index.html exists in the new OSS directory, but it must remain on the web server.'
}
} catch {
if (-not $_.Exception.Response) {
throw
}
$RemoteIndexStatus = [int]$_.Exception.Response.StatusCode
if ($RemoteIndexStatus -ne 404) {
throw "Unable to confirm that index.html was excluded. HTTP status: $RemoteIndexStatus"
}
}
if (-not $SkipRemoteVerify) {
Write-Host '[5/5] Verifying uploaded JS, CSS, and image URLs' -ForegroundColor Cyan
$ScriptMatch = [regex]::Match($IndexContent, '<script[^>]+src="([^"]+)"')
$StyleMatch = [regex]::Match($IndexContent, '<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"')
if (-not $ScriptMatch.Success -or -not $StyleMatch.Success) {
throw 'Could not locate generated JS or CSS URLs in index.html.'
}
$CacheBuster = "verify=$Timestamp"
$ScriptUrl = $ScriptMatch.Groups[1].Value
$StyleUrl = $StyleMatch.Groups[1].Value
$ScriptResponse = Invoke-WebRequest -Uri "${ScriptUrl}?$CacheBuster" -UseBasicParsing
$StyleResponse = Invoke-WebRequest -Uri "${StyleUrl}?$CacheBuster" -Method Head -UseBasicParsing
if ($ScriptResponse.StatusCode -ne 200 -or $StyleResponse.StatusCode -ne 200) {
throw 'The uploaded JS or CSS file is not publicly accessible.'
}
if ($ScriptResponse.Content.Contains($DuplicatePrefix) -or
$ScriptResponse.Content -match 'https://[^\s"''`]+https://') {
throw 'The uploaded JS contains a duplicated URL.'
}
$SampleImage = "${PublicBase}site/assets/logo.png"
$ImageResponse = Invoke-WebRequest -Uri "${SampleImage}?$CacheBuster" -Method Head -UseBasicParsing
if ($ImageResponse.StatusCode -ne 200) {
throw 'The uploaded sample image is not publicly accessible.'
}
} else {
Write-Host '[5/5] Remote verification skipped by parameter' -ForegroundColor Yellow
}
$SizeMiB = [math]::Round($UploadBytes / 1MB, 2)
Write-Host ''
Write-Host 'Deployment completed successfully.' -ForegroundColor Green
Write-Host "OSS directory : $OssTarget"
Write-Host "Public base : $PublicBase"
Write-Host "Uploaded : $($UploadFiles.Count) files / $SizeMiB MiB"
Write-Host "Server index : $IndexPath"
} finally {
Pop-Location
}

View File

@ -0,0 +1,190 @@
<template>
<div class="poster-gallery">
<button
v-for="(image, index) in images"
:key="image.src"
class="poster-gallery__card"
type="button"
:aria-label="`全屏查看${image.alt || `海报 ${index + 1}`}`"
@click="open(index)"
>
<img :src="image.src" :alt="image.alt" loading="lazy" />
<span>全屏查看 <b>↗</b></span>
</button>
</div>
<Teleport to="body">
<div
v-if="activeIndex > -1"
ref="lightbox"
class="poster-lightbox"
role="dialog"
aria-modal="true"
aria-label="海报全屏查看"
tabindex="-1"
@keydown.esc="close"
@keydown.left="showPrevious"
@keydown.right="showNext"
>
<div class="poster-lightbox__backdrop" @click="close"></div>
<div class="poster-lightbox__topbar">
<div>
<strong>{{ currentImage.alt || '跃动运动馆海报' }}</strong>
<small>{{ activeIndex + 1 }} / {{ images.length }}</small>
</div>
<button type="button" aria-label="关闭全屏海报" @click="close">×</button>
</div>
<button
v-if="images.length > 1"
class="poster-lightbox__arrow poster-lightbox__arrow--previous"
type="button"
aria-label="查看上一张海报"
@click="showPrevious"
>‹</button>
<div
ref="viewport"
class="poster-lightbox__viewport"
:class="{ 'is-draggable': zoom > 1, 'is-dragging': isDragging }"
@wheel.prevent="onWheel"
@pointerdown="startDrag"
@pointermove="drag"
@pointerup="endDrag"
@pointercancel="endDrag"
>
<img
ref="posterImage"
:src="currentImage.src"
:alt="currentImage.alt"
:style="{
width: fittedSize.width ? `${fittedSize.width}px` : 'auto',
height: fittedSize.height ? `${fittedSize.height}px` : 'auto',
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${zoom})`
}"
draggable="false"
@load="fitPosterToViewport"
@dblclick="toggleZoom"
/>
</div>
<button
v-if="images.length > 1"
class="poster-lightbox__arrow poster-lightbox__arrow--next"
type="button"
aria-label="查看下一张海报"
@click="showNext"
>›</button>
<div class="poster-lightbox__controls" aria-label="海报缩放控制">
<button type="button" aria-label="缩小海报" :disabled="zoom <= 1" @click="changeZoom(-0.25)">−</button>
<button type="button" class="poster-lightbox__scale" aria-label="恢复原始缩放" @click="resetZoom">
{{ zoom === 1 ? '适合屏幕' : `${Math.round(zoom * 100)}%` }}
</button>
<button type="button" aria-label="放大海报" :disabled="zoom >= 4" @click="changeZoom(0.25)">+</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
const props = defineProps({
images: { type: Array, default: () => [] }
});
const activeIndex = ref(-1);
const lightbox = ref(null);
const viewport = ref(null);
const posterImage = ref(null);
const zoom = ref(1);
const offset = reactive({ x: 0, y: 0 });
const fittedSize = reactive({ width: 0, height: 0 });
const isDragging = ref(false);
let dragOrigin = null;
const currentImage = computed(() => props.images[activeIndex.value] || {});
const resetZoom = () => {
zoom.value = 1;
offset.x = 0;
offset.y = 0;
};
const fitPosterToViewport = () => {
const frame = viewport.value;
const image = posterImage.value;
if (!frame || !image?.naturalWidth || !image?.naturalHeight) return;
const scale = Math.min(
frame.clientWidth / image.naturalWidth,
frame.clientHeight / image.naturalHeight
);
fittedSize.width = Math.floor(image.naturalWidth * scale);
fittedSize.height = Math.floor(image.naturalHeight * scale);
};
const open = async (index) => {
activeIndex.value = index;
resetZoom();
document.body.classList.add('poster-lightbox-open');
await nextTick();
fitPosterToViewport();
lightbox.value?.focus();
};
const close = () => {
activeIndex.value = -1;
resetZoom();
document.body.classList.remove('poster-lightbox-open');
};
const show = (index) => {
activeIndex.value = (index + props.images.length) % props.images.length;
resetZoom();
nextTick(fitPosterToViewport);
};
const showPrevious = () => show(activeIndex.value - 1);
const showNext = () => show(activeIndex.value + 1);
const changeZoom = (amount) => {
zoom.value = Math.min(4, Math.max(1, Number((zoom.value + amount).toFixed(2))));
if (zoom.value === 1) {
offset.x = 0;
offset.y = 0;
}
};
const onWheel = (event) => changeZoom(event.deltaY < 0 ? 0.25 : -0.25);
const toggleZoom = () => {
if (zoom.value > 1) resetZoom();
else zoom.value = 2;
};
const startDrag = (event) => {
if (zoom.value <= 1) return;
isDragging.value = true;
dragOrigin = { x: event.clientX - offset.x, y: event.clientY - offset.y };
event.currentTarget.setPointerCapture?.(event.pointerId);
};
const drag = (event) => {
if (!isDragging.value || !dragOrigin) return;
offset.x = event.clientX - dragOrigin.x;
offset.y = event.clientY - dragOrigin.y;
};
const endDrag = () => {
isDragging.value = false;
dragOrigin = null;
};
onMounted(() => window.addEventListener('resize', fitPosterToViewport));
onBeforeUnmount(() => {
window.removeEventListener('resize', fitPosterToViewport);
document.body.classList.remove('poster-lightbox-open');
});
</script>

View File

@ -0,0 +1,42 @@
<template>
<section class="purchase-channels" :class="{ compact }" aria-label="购买渠道">
<div class="purchase-channel-copy">
<small>OFFICIAL STORES</small>
<h2>购买渠道</h2>
<p>前往跃动线上店铺,查看在售产品、规格与最新活动。</p>
</div>
<div class="purchase-channel-links">
<a
v-for="channel in channels"
:key="channel.name"
:href="channel.url"
target="_blank"
rel="noopener noreferrer"
:aria-label="`前往跃动${channel.name}店铺(新窗口打开)`"
>
<span>{{ channel.code }}</span>
<div><small>{{ channel.en }}</small><strong>{{ channel.name }}</strong></div>
<b>立即购买 ↗</b>
</a>
</div>
</section>
</template>
<script setup>
defineProps({ compact: { type: Boolean, default: false } });
const channels = [
{
name: '京东',
en: 'JD.COM',
code: 'JD',
url: 'https://mall.jd.com/index-43897.html?from=pc&cid=0'
},
{
name: '淘宝',
en: 'TAOBAO · TMALL',
code: 'TM',
url: 'https://yuedongyundong.tmall.com/shop/view_shop.htm?spm=a21xtw.29978516.0.0'
}
];
</script>

View File

@ -4,9 +4,9 @@
<div class="footer-orbit footer-orbit-two"></div>
<div class="footer-inner">
<div class="footer-brand"><img :src="logoUrl" alt="跃动 YUEDONG" /><p>让世界爱上跳绳</p><small>MAKE THE WORLD LOVE ROPE SKIPPING</small></div>
<div><b>探索跃动</b><RouterLink to="/about/brand-story">关于我们</RouterLink><RouterLink to="/products/categories">产品装备</RouterLink></div>
<div><b>服务支持</b><RouterLink to="/education/venues">教育培训</RouterLink><RouterLink to="/contact/contact-info">业务合作</RouterLink></div>
<div><b>连接我们</b><RouterLink to="/contact/social">社媒矩阵</RouterLink><RouterLink to="/contact/venues">场馆地址</RouterLink></div>
<div><b>探索跃动</b><RouterLink to="/about/brand-story">关于跃动</RouterLink><RouterLink to="/sports/competition">跃动体育</RouterLink></div>
<div><b>业务版图</b><RouterLink to="/technology">跃动科技</RouterLink><RouterLink to="/health">跃动健康</RouterLink><RouterLink to="/factory/supply-chain">跃动工厂</RouterLink></div>
<div><b>连接我们</b><RouterLink to="/ygjrc">YGJRC</RouterLink><RouterLink to="/contact">联系我们</RouterLink></div>
</div>
<div class="copyright"><span>© {{ new Date().getFullYear() }} YUEDONG. All rights reserved.</span><span>SHANGHAI · CHINA</span></div>
</footer>

View File

@ -4,8 +4,21 @@
<RouterLink class="brand" to="/" aria-label="跃动首页" @click="closeMenu"><img :src="logoUrl" alt="跃动 YUEDONG" /></RouterLink>
<nav class="main-nav" aria-label="主导航">
<div v-for="item in nav" :key="item.label" class="nav-item">
<RouterLink :to="item.to" @click="closeMenu"><span>{{ item.label }}</span><i v-if="item.children"></i></RouterLink>
<div v-if="item.children" class="dropdown"><span class="dropdown-label">{{ item.label }}</span><RouterLink v-for="child in item.children" :key="child.label" :to="child.to" @click="closeMenu">{{ child.label }}</RouterLink></div>
<RouterLink :to="item.to" :class="{ 'section-active': isSectionActive(item) }" @click="closeMenu"><span>{{ item.label }}</span><i v-if="item.children || item.groups"></i></RouterLink>
<div v-if="item.children || item.groups" class="dropdown" :class="{ 'dropdown-mega': item.groups }">
<span class="dropdown-label">{{ item.label }}</span>
<template v-if="item.groups">
<div class="dropdown-groups">
<section v-for="group in item.groups" :key="group.label" class="dropdown-group">
<RouterLink class="dropdown-group-title" :to="group.to" @click="closeMenu">{{ group.label }}</RouterLink>
<RouterLink v-for="child in group.children" :key="child.label" :to="child.to" @click="closeMenu">{{ child.label }}</RouterLink>
</section>
</div>
</template>
<template v-else>
<RouterLink v-for="child in item.children" :key="child.label" :to="child.to" @click="closeMenu">{{ child.label }}</RouterLink>
</template>
</div>
</div>
</nav>
<div class="nav-actions">
@ -29,6 +42,10 @@ const scrolled = ref(false);
const headerHidden = ref(false);
const logoUrl = '/site/assets/logo.png';
const closeMenu = () => { menuOpen.value = false; };
const isSectionActive = (item) => {
if (item.to === '/') return route.path === '/';
return route.path === item.to || route.path.startsWith(`${item.to.split('/').slice(0, 2).join('/')}/`);
};
let lastScrollY = 0;
let scrollFrame = 0;
@ -75,11 +92,52 @@ onUnmounted(() => {
const nav = [
{ label: '首页', to: '/' },
{ label: '关于跃动', to: '/about/brand-story', children: [{ label: '品牌故事', to: '/about/brand-story' }, { label: '跃动历程', to: '/about/history' }, { label: '动态公告', to: '/about/notices' }] },
{ label: '赛事资讯', to: '/events/news', children: [{ label: '赛事新闻', to: '/events/news' }, { label: '赛事发布', to: '/events/releases' }, { label: '活动公告', to: '/events/notices' }] },
{ label: '产品装备', to: '/products/categories', children: [{ label: '装备分类', to: '/products/categories' }, { label: '供应链', to: '/products/supply-chain' }, { label: '开发定制', to: '/products/custom' }] },
{ label: '教育培训', to: '/education/venues', children: [{ label: '跃动运动馆', to: '/education/venues' }, { label: '寒暑营', to: '/education/camps' }, { label: '训练营', to: '/education/training' }] },
{ label: '跃动科技', to: '/technology' }, { label: '跃动健康', to: '/health' }, { label: 'YGJRC', to: '/ygjrc' },
{ label: '联系我们', to: '/contact/social', children: [{ label: '社媒与合作', to: '/contact/social' }, { label: '场馆地址', to: '/contact/venues' }, { label: '联系方式', to: '/contact/contact-info' }] }
{ label: '关于跃动', to: '/about/brand-story', children: [{ label: '品牌故事', to: '/about/brand-story' }, { label: '跃动历程', to: '/about/history' }, { label: '集团动态', to: '/about/group-news' }] },
{
label: '跃动体育',
to: '/sports/competition',
groups: [
{
label: '竞赛演艺',
to: '/sports/competition',
children: [
{ label: '赛事招募', to: '/sports/competition#event-recruitment' },
{ label: '赛事新闻', to: '/sports/competition#event-news' },
{ label: '演艺活动', to: '/sports/competition#performances' }
]
},
{
label: '跃动运动馆',
to: '/sports/venues',
children: [
{ label: '运动馆介绍', to: '/sports/venues#venue-intro' },
{ label: '冠军班', to: '/sports/venues#champion-class' },
{ label: '特色训练营', to: '/sports/venues#camps' },
{ label: '运动馆地址', to: '/sports/venues#venue-address' }
]
},
{
label: '跃动装备',
to: '/sports/equipment',
children: [
{ label: '1阶 启蒙型', to: '/sports/equipment-stage-1' },
{ label: '2阶 初学型', to: '/sports/equipment-stage-2' },
{ label: '3阶 提速型', to: '/sports/equipment-stage-3' },
{ label: '4阶 考测型', to: '/sports/equipment-stage-4' },
{ label: '5阶 竞技型', to: '/sports/equipment-stage-5' },
{ label: '校园跳绳', to: '/sports/equipment-campus' },
{ label: '健身跳绳', to: '/sports/equipment-fitness' },
{ label: '跳绳鞋服', to: '/sports/equipment-apparel' },
{ label: '其它周边', to: '/sports/equipment-accessories' },
{ label: '购买渠道', to: '/sports/purchase-channels' }
]
}
]
},
{ label: '跃动科技', to: '/technology' },
{ label: '跃动健康', to: '/health' },
{ label: '跃动工厂', to: '/factory/supply-chain', children: [{ label: '供应链', to: '/factory/supply-chain' }, { label: 'KA定制', to: '/factory/ka-custom' }] },
{ label: 'YGJRC', to: '/ygjrc' },
{ label: '联系我们', to: '/contact' }
];
</script>

View File

@ -0,0 +1,177 @@
<template>
<div class="sports-grouped-layout">
<aside class="sports-side-nav">
<small>{{ mode === 'competition' ? 'COMPETITION & PERFORMANCE' : 'YOUTH SPORTS VENUES' }}</small>
<nav :aria-label="`${title}页面导航`">
<a
v-for="(item, index) in navigation"
:key="item.id"
:href="`#${item.id}`"
:class="{ active: activeSection === item.id }"
@click.prevent="navigateToSection(item.id)"
>
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ item.label }}
</a>
</nav>
</aside>
<div v-if="mode === 'competition'" class="sports-grouped-content">
<section
v-for="panel in competitionPanels"
:id="panel.id"
:key="panel.id"
:ref="setSectionRef"
class="sports-grouped-section"
>
<header class="sports-grouped-heading">
<small>{{ panel.eyebrow }}</small>
<h2>{{ panel.label }}</h2>
<p>{{ panel.description }}</p>
</header>
<div v-if="panel.content.articles?.length" class="article-list sports-grouped-articles">
<RouterLink
v-for="(article, index) in panel.content.articles"
:key="`${panel.id}-${article.title}`"
:to="`/detail/sports/${panel.id}/${index}`"
>
<div class="article-no">{{ String(index + 1).padStart(2, '0') }}</div>
<div><h3>{{ article.title }}</h3><p v-for="line in article.body.slice(0, 2)" :key="line">{{ line }}</p></div>
</RouterLink>
</div>
<div v-else class="sports-performance-block">
<div class="prose"><p v-for="line in panel.content.body" :key="line">{{ line }}</p></div>
<div class="sports-grouped-gallery">
<img
v-for="image in panel.content.gallery?.slice(0, 6)"
:key="image.src"
:src="image.src"
:alt="image.alt"
loading="lazy"
/>
</div>
</div>
</section>
</div>
<div v-else class="sports-grouped-content">
<section id="venue-intro" :ref="setSectionRef" class="sports-grouped-section">
<header class="sports-grouped-heading">
<small>VENUE INTRODUCTION</small><h2>运动馆介绍</h2>
<p>了解跃动运动馆的品牌体系、课程理念与全国布局。</p>
</header>
<SportsHallSection />
</section>
<section id="champion-class" :ref="setSectionRef" class="sports-grouped-section">
<header class="sports-grouped-heading">
<small>CHAMPION CLASS</small><h2>冠军班</h2>
<p>由专业教练团队打造的进阶竞技培养课程。</p>
</header>
<div class="sports-text-gallery">
<div class="prose"><p v-for="line in venueSections['champion-class'].body" :key="line">{{ line }}</p></div>
<PosterGallery :images="venueSections['champion-class'].gallery" />
</div>
</section>
<section id="camps" :ref="setSectionRef" class="sports-grouped-section">
<header class="sports-grouped-heading">
<small>FEATURED CAMPS</small><h2>特色训练营</h2>
<p>围绕假期集训、体测培优和专项突破开展主题课程。</p>
</header>
<div class="sports-text-gallery">
<div class="prose"><p v-for="line in venueSections.camps.body" :key="line">{{ line }}</p></div>
<PosterGallery :images="venueSections.camps.gallery" />
</div>
</section>
<section id="venue-address" :ref="setSectionRef" class="sports-grouped-section">
<header class="sports-grouped-heading">
<small>VENUE NETWORK</small><h2>运动馆地址</h2>
<p>按城市查找全国跃动运动馆及合作场馆。</p>
</header>
<VenueAddressSection />
</section>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { pages } from '../data/page-content.js';
import PosterGallery from './PosterGallery.vue';
import SportsHallSection from './SportsHallSection.vue';
import VenueAddressSection from './VenueAddressSection.vue';
const props = defineProps({
mode: { type: String, required: true }
});
const router = useRouter();
const activeSection = ref('');
const sectionElements = [];
let observer;
const competitionPanels = computed(() => [
{
id: 'event-recruitment',
label: '赛事招募',
eyebrow: 'EVENT REGISTRATION',
description: '汇集跃动旗下赛事报名、参赛通知与活动信息。',
content: pages.sports.sections['event-recruitment']
},
{
id: 'event-news',
label: '赛事新闻',
eyebrow: 'EVENT NEWS',
description: '关注专业赛事、城市活动与青少年运动员的最新动态。',
content: pages.sports.sections['event-news']
},
{
id: 'performances',
label: '演艺活动',
eyebrow: 'PERFORMANCE',
description: '冠军团队、花样跳绳与大型活动演出服务。',
content: pages.sports.sections.performances
}
]);
const venueSections = pages.sports.sections;
const navigation = computed(() => props.mode === 'competition'
? competitionPanels.value.map(({ id, label }) => ({ id, label }))
: [
{ id: 'venue-intro', label: '运动馆介绍' },
{ id: 'champion-class', label: '冠军班' },
{ id: 'camps', label: '特色训练营' },
{ id: 'venue-address', label: '运动馆地址' }
]);
const title = computed(() => props.mode === 'competition' ? '竞赛演艺' : '跃动运动馆');
const setSectionRef = (element) => {
if (element && !sectionElements.includes(element)) sectionElements.push(element);
};
const navigateToSection = async (sectionId) => {
const target = document.getElementById(sectionId);
if (!target) return;
activeSection.value = sectionId;
await router.replace({ hash: `#${sectionId}` });
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
onMounted(() => {
activeSection.value = location.hash.slice(1) || navigation.value[0].id;
observer = new IntersectionObserver((entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) activeSection.value = visible.target.id;
}, { rootMargin: '-18% 0px -62% 0px', threshold: [0, 0.1, 0.3] });
sectionElements.forEach((element) => observer.observe(element));
});
onBeforeUnmount(() => observer?.disconnect());
</script>

View File

@ -0,0 +1,63 @@
<template>
<div class="venue-directory">
<p v-reveal class="venue-summary">
场馆按城市平铺展示,便于快速查找附近的跃动运动馆。门店信息可能调整,到访前可通过“联系我们”确认。
</p>
<div v-for="city in venueGroups" :key="city.city" class="venue-city">
<div v-reveal class="venue-city-title">
<span>{{ city.en }}</span>
<h3>{{ city.city }}</h3>
<b>{{ city.venues.length }} 家场馆</b>
</div>
<div class="venue-grid">
<a
v-for="(venue, index) in city.venues"
:key="venue.name"
v-reveal
:href="mapNavigationUrl(city.city, venue)"
:aria-label="`使用高德地图导航到${mapVenueName(venue)}`"
target="_blank"
rel="noopener noreferrer"
:style="{ '--delay': `${(index % 4) * 55}ms` }"
>
<span class="venue-pin"></span>
<div>
<h4>{{ mapVenueName(venue) }}</h4>
<p>{{ venue.address }}</p>
<b class="venue-map-link">地图导航 <span>→</span></b>
</div>
</a>
</div>
</div>
</div>
</template>
<script setup>
import { venueGroups } from '../data/venue-groups.js';
const mapVenueName = (venue) => `跃动运动馆(${venue.name})`;
const mapNavigationUrl = (city, venue) => {
const params = new URLSearchParams({
keyword: `${mapVenueName(venue)} ${venue.address}`,
city,
view: 'map',
src: 'yuedong-sports',
callnative: '1'
});
return `https://uri.amap.com/search?${params.toString()}`;
};
const vReveal = {
mounted(el) {
el.classList.add('reveal');
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
el.classList.add('is-visible');
observer.disconnect();
}
}, { threshold: 0.1 });
observer.observe(el);
}
};
</script>

View File

@ -144,7 +144,6 @@ export const pages = {
};
pages.about.sections['brand-story'].body = brandIntroduction;
pages.about.sections['brand-story'].timeline = aboutTimeline;
pages.about.sections.history.body = [];
pages.about.sections.history.timeline = aboutTimeline;
@ -171,3 +170,149 @@ Object.assign(pages.education.sections.venues, {
{ src: '/sports-hall/competition-collage.png', alt: '学员训练与比赛精彩瞬间' }
]
});
// 官网栏目结构以《跃动官网.xmind》为准。旧的 events / products /
// education 数据继续作为内容源,新的 sports / factory 页面负责对外呈现。
pages.about.tabs = [
{ key: 'brand-story', label: '品牌故事' },
{ key: 'history', label: '跃动历程' },
{ key: 'group-news', label: '集团动态' }
];
pages.about.sections['group-news'] = {
...pages.about.sections.notices,
title: '集团动态'
};
const eventImages = images('赛事资讯');
const educationImages = images('教育培训');
const productHero = materials.products.find((product) => product.image)?.image;
const productsIn = (...categories) => productDetails.filter((product) => categories.includes(product.category));
const overviewCard = (title, description, to) => ({ title, body: [description], to });
pages.sports = {
eyebrow: 'YUEDONG SPORTS',
title: '跃动体育',
summary: '连接竞赛演艺、跃动运动馆与专业装备,构建覆盖赛事、培训和运动消费的体育服务体系。',
hero: pages.events.hero,
tabs: [
{ key: 'competition', label: '竞赛演艺' },
{ key: 'venues', label: '跃动运动馆' },
{ key: 'equipment', label: '跃动装备' }
],
sections: {
competition: {
title: '竞赛演艺',
body: ['以专业赛事运营、青少年竞赛和冠军团队演艺为核心,让跳绳运动连接城市、校园与大众舞台。'],
gallery: eventImages.slice(0, 5),
cards: [
overviewCard('赛事招募', '汇集跃动旗下赛事报名、参赛通知与活动信息。', '/sports/competition#event-recruitment'),
overviewCard('赛事新闻', '关注专业赛事、城市活动与青少年运动员的最新动态。', '/sports/competition#event-news'),
overviewCard('演艺活动', '冠军团队、花样跳绳与大型活动演出服务。', '/sports/competition#performances')
]
},
'event-recruitment': {
...pages.events.sections.releases,
parent: 'competition',
title: '赛事招募'
},
'event-news': {
...pages.events.sections.news,
parent: 'competition',
title: '赛事新闻'
},
performances: {
parent: 'competition',
title: '演艺活动',
body: ['跃动冠军团队将竞技实力、花样创意与舞台表达融为一体,为体育赛事、品牌活动、校园庆典和城市文化项目提供专业演艺服务。'],
gallery: eventImages.slice(20, 32)
},
venues: {
parent: 'venues',
title: '跃动运动馆',
body: ['跃动运动馆以跳绳为核心,融合体适能、专项技能和成长训练,为青少年提供科学、系统、可持续的运动课程。'],
gallery: educationImages.slice(0, 4),
cards: [
overviewCard('运动馆介绍', '了解跃动运动馆的品牌体系、课程理念与全国布局。', '/sports/venues#venue-intro'),
overviewCard('冠军班', '由专业教练团队打造的进阶竞技培养课程。', '/sports/venues#champion-class'),
overviewCard('特色训练营', '围绕假期集训、体测培优和专项突破开展主题课程。', '/sports/venues#camps'),
overviewCard('运动馆地址', '查找全国跃动运动馆及合作场馆。', '/sports/venues#venue-address')
]
},
'venue-intro': {
...pages.education.sections.venues,
parent: 'venues',
title: '运动馆介绍'
},
'champion-class': {
parent: 'venues',
title: '冠军班',
body: [
'冠军班面向具备专项基础、希望进一步提升竞技能力的学员,围绕速度、耐力、花样与临场表现进行系统训练。',
'课程通过阶段测评、训练计划和赛事实践,帮助学员建立稳定技术、竞赛意识和长期成长目标。'
],
gallery: educationImages.slice(0, 4)
},
camps: {
...pages.education.sections.camps,
parent: 'venues',
title: '特色训练营'
},
'venue-address': {
parent: 'venues',
title: '运动馆地址'
},
equipment: {
parent: 'equipment',
title: '跃动分阶跳绳',
products: productDetails
},
'equipment-stage-1': { parent: 'equipment', title: '1阶 · 启蒙型', products: productsIn('竹节跳绳') },
'equipment-stage-2': { parent: 'equipment', title: '2阶 · 初学型', products: productsIn('分段跳绳') },
'equipment-stage-3': { parent: 'equipment', title: '3阶 · 提速型', products: productsIn('胶绳') },
'equipment-stage-4': { parent: 'equipment', title: '4阶 · 考测型', products: productsIn('电子计数跳绳') },
'equipment-stage-5': { parent: 'equipment', title: '5阶 · 竞技型', products: productsIn('速度跳绳', '交互绳') },
'equipment-campus': { parent: 'equipment', title: '校园跳绳', products: productsIn('分段跳绳', '电子计数跳绳') },
'equipment-fitness': { parent: 'equipment', title: '健身跳绳', products: productsIn('健身跳绳') },
'equipment-apparel': {
parent: 'equipment',
title: '跳绳鞋服',
body: ['围绕日常训练、竞赛和团队形象需求,逐步完善专业跳绳鞋服产品体系。'],
gallery: productHero ? [{ src: productHero, alt: '跃动跳绳鞋服与装备' }] : []
},
'equipment-accessories': {
parent: 'equipment',
title: '其它周边',
body: ['提供与跳绳训练、赛事组织和团队活动配套的周边产品及组合方案。'],
gallery: images('产品装备').slice(0, 4)
},
'purchase-channels': {
parent: 'equipment',
title: '购买渠道',
body: ['跃动产品可通过官方社媒、电商平台、线下运动馆及团体采购渠道了解与购买。'],
cards: [overviewCard('联系装备服务', '获取产品选型、团体采购和购买渠道支持。', '/contact/contact-info')]
}
}
};
pages.technology.sections.default.title = '1+4+N 智慧体育解决方案';
pages.factory = {
eyebrow: 'YUEDONG FACTORY',
title: '跃动工厂',
summary: '以稳定供应链、研发制造与定制服务,为品牌、赛事、学校和机构提供专业装备解决方案。',
hero: images('产品装备')[0] || (productHero ? { src: productHero, alt: '跃动工厂' } : null),
tabs: [
{ key: 'supply-chain', label: '供应链' },
{ key: 'ka-custom', label: 'KA定制' }
],
sections: {
'supply-chain': {
...pages.products.sections['supply-chain'],
title: '供应链'
},
'ka-custom': {
...pages.products.sections.custom,
title: 'KA定制'
}
}
};

82
src/data/venue-groups.js Normal file
View File

@ -0,0 +1,82 @@
export const venueGroups = [
{
city: '上海',
en: 'SHANGHAI',
venues: [
{ name: '杨浦翔殷馆', address: '上海市杨浦区翔殷路260号' },
{ name: '杨浦新江湾运动健康中心馆', address: '上海市杨浦区国浩路701号201室' },
{ name: '宝山高境三邻桥馆', address: '上海市宝山区江杨南路485号三邻桥体育文化园11号101B室' },
{ name: '宝山体育中心馆', address: '上海市宝山区永清路700号体育中心内' },
{ name: '闵行七宝宝龙广场馆', address: '上海市闵行区漕宝路3299弄七宝宝龙广场3楼' },
{ name: '浦东金桥馆', address: '上海市浦东新区金藏路1296弄151号2楼2027室' },
{ name: '静安盛源生活广场馆', address: '上海市静安区柳营路777号盛源生活广场2楼' },
{ name: '静安889GLO广场馆', address: '上海市静安区万航渡路889号889广场3楼' },
{ name: '闵行力波馆', address: '上海市闵行区虹梅南路379弄27号力波九坊' },
{ name: '浦东恒生万鹂广场馆', address: '上海市浦东新区芳甸路115弄恒生万鹂广场' },
{ name: '虹口瑞虹星星堂馆', address: '上海市虹口区天宝路280弄瑞虹天地星星堂' },
{ name: '普陀曹杨复悦荟馆', address: '上海市普陀区曹杨路1888号复悦荟3楼' },
{ name: '青浦区跃动青少儿运动健康促进中心', address: '上海市青浦区淀山湖路535号4楼' }
]
},
{
city: '北京',
en: 'BEIJING',
venues: [
{ name: '西城官园馆', address: '北京市西城区西直门南小街2号楼B1-103' },
{ name: '丰台草桥馆', address: '北京市丰台区草桥欣园一区5号楼2楼260室' },
{ name: '西城马连道馆', address: '北京市西城区马连道街华睦大厦' },
{ name: '东城崇文门馆', address: '北京市东城区崇文门区域' },
{ name: '西城西便门馆', address: '北京市西城区西便门区域' },
{ name: '朝阳大郊亭馆', address: '北京市朝阳区大郊亭华腾新天地运动中心' }
]
},
{
city: '天津',
en: 'TIANJIN',
venues: [
{ name: '天津南开馆', address: '天津市南开区宾川道507号' },
{ name: '天津和平馆', address: '天津市和平区人民体育馆区域' }
]
},
{
city: '广州',
en: 'GUANGZHOU',
venues: [
{ name: '天河林和东馆', address: '广州市天河区林和东路218号2楼自编03室' },
{ name: '海珠广纸天地荟馆', address: '广州市海珠区工业大道广纸天地荟207-208' }
]
},
{
city: '徐州',
en: 'XUZHOU',
venues: [
{ name: '经开淮海环球港馆', address: '徐州市经开区淮海环球港L2096' },
{ name: '鼓楼金地商都馆', address: '徐州市鼓楼区中山北路6号金地商都' }
]
},
{
city: '合肥',
en: 'HEFEI',
venues: [
{ name: '庐阳华润橡树湾馆', address: '合肥市庐阳区华润橡树湾商业区' },
{ name: '包河馆', address: '合肥市包河区安徽省运动健康促进中心2楼' }
]
},
{
city: '成都',
en: 'CHENGDU',
venues: [
{ name: '武侯莱蒙都会馆', address: '成都市武侯区二环路南四段51号莱蒙都会商场4楼' }
]
},
{
city: '加盟场馆',
en: 'FRANCHISE VENUES',
venues: [
{ name: '松江大学城馆', address: '上海市松江区梅家浜路1501号3楼' },
{ name: '广西南宁馆', address: '南宁市青秀区星湖路1号' },
{ name: '江西信丰馆', address: '赣州市信丰县体育中心内' },
{ name: '江西上饶馆', address: '上饶市广信区旭日街道武夷山大道博华年华北区2栋111-113' }
]
}
];

View File

@ -1,4 +1,4 @@
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
import { createRouter, createWebHashHistory } from 'vue-router';
import HomePage from './views/HomePage.vue';
import ContentPage from './views/ContentPage.vue';
import ContentDetailPage from './views/ContentDetailPage.vue';
@ -7,27 +7,56 @@ import ContactPage from './views/ContactPage.vue';
const routes = [
{ path: '/', name: 'home', component: HomePage },
{ path: '/about/notices', redirect: '/about/group-news' },
{ path: '/about/:section?', component: ContentPage, meta: { page: 'about' } },
{ path: '/events/:section?', component: ContentPage, meta: { page: 'events' } },
{ path: '/products/:section?', component: ContentPage, meta: { page: 'products' } },
{ path: '/education/:section?', component: ContentPage, meta: { page: 'education' } },
{ path: '/sports/equipment/detail/:id', name: 'sports-product-detail', component: ProductDetailPage, meta: { page: 'sports' } },
{ path: '/sports/event-recruitment', redirect: '/sports/competition#event-recruitment' },
{ path: '/sports/event-news', redirect: '/sports/competition#event-news' },
{ path: '/sports/performances', redirect: '/sports/competition#performances' },
{ path: '/sports/venue-intro', redirect: '/sports/venues#venue-intro' },
{ path: '/sports/champion-class', redirect: '/sports/venues#champion-class' },
{ path: '/sports/camps', redirect: '/sports/venues#camps' },
{ path: '/sports/venue-address', redirect: '/sports/venues#venue-address' },
{ path: '/sports/:section?', component: ContentPage, meta: { page: 'sports' } },
{ path: '/technology', component: ContentPage, meta: { page: 'technology' } },
{ path: '/health', component: ContentPage, meta: { page: 'health' } },
{ path: '/factory/:section?', component: ContentPage, meta: { page: 'factory' } },
{ path: '/ygjrc', component: ContentPage, meta: { page: 'ygjrc' } },
{ path: '/contact/venues', redirect: '/sports/venues#venue-address' },
{ path: '/contact/:section?', component: ContactPage, meta: { page: 'contact' } },
{ path: '/products/detail/:id', name: 'product-detail', component: ProductDetailPage, meta: { page: 'products' } },
{
path: '/events/:section?',
redirect: (to) => ({
news: '/sports/competition#event-news',
releases: '/sports/competition#event-recruitment',
notices: '/sports/competition#performances'
})[to.params.section] || '/sports/competition'
},
{
path: '/products/:section?',
redirect: (to) => `/sports/${({ categories: 'equipment', 'supply-chain': 'equipment', custom: 'purchase-channels' })[to.params.section] || 'equipment'}`
},
{
path: '/education/:section?',
redirect: (to) => ({
venues: '/sports/venues#venue-intro',
camps: '/sports/venues#camps',
training: '/sports/venues#champion-class'
})[to.params.section] || '/sports/venues'
},
{ path: '/detail/:page/:section/:index?', name: 'content-detail', component: ContentDetailPage },
{ path: '/:pathMatch(.*)*', redirect: '/' }
];
export default createRouter({
history: import.meta.env.VITE_STATIC_DEPLOY === 'oss'
? createWebHashHistory()
: createWebHistory(),
history: createWebHashHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.hash) return { el: to.hash, top: 138, behavior: 'smooth' };
if (to.name === 'product-detail' || to.name === 'content-detail') {
return { top: 0 };
}

View File

@ -48,8 +48,8 @@ main { min-height: 72vh; }
.nav-item > a i { width: 5px; height: 5px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform .25s; }
.nav-item:hover > a i { transform: translateY(1px) rotate(225deg); }
.nav-item > a::after { content: ""; position: absolute; left: 50%; right: 50%; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: linear-gradient(90deg, var(--blue-500), var(--blue-700)); transition: left .3s ease, right .3s ease; }
.nav-item:hover > a, .nav-item > a.router-link-active { color: var(--blue-700); }
.nav-item:hover > a::after, .nav-item > a.router-link-active::after { left: 14px; right: 14px; }
.nav-item:hover > a, .nav-item > a.router-link-active, .nav-item > a.section-active { color: var(--blue-700); }
.nav-item:hover > a::after, .nav-item > a.router-link-active::after, .nav-item > a.section-active::after { left: 14px; right: 14px; }
.dropdown {
position: absolute; top: 72px; left: 50%; width: 190px; padding: 12px;
background: rgba(255,255,255,.97); border: 1px solid rgba(0,144,255,.14); border-radius: 14px;
@ -63,6 +63,13 @@ main { min-height: 72vh; }
.dropdown a::before { content: ""; position: absolute; left: 10px; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: var(--blue-300); transform: translateY(-50%); transition: .22s; }
.dropdown a:hover, .dropdown a.router-link-active { color: var(--blue-700); background: var(--blue-50); transform: translateX(3px); }
.dropdown a:hover::before { background: var(--blue-600); box-shadow: 0 0 0 4px rgba(0,144,255,.12); }
.dropdown-mega { width: min(820px,calc(100vw - 48px)); padding: 18px; }
.dropdown-groups { display: grid; grid-template-columns: .85fr 1fr 1.45fr; gap: 12px; }
.dropdown-group { min-width: 0; padding: 6px; border-radius: 11px; background: #f8fbff; }
.dropdown .dropdown-group-title { margin: 0 0 5px; padding: 11px 12px; color: var(--blue-800); background: #eaf6ff; font-size: 14px; font-weight: 800; }
.dropdown .dropdown-group-title::before { display: none; }
.dropdown .dropdown-group-title:hover { transform: none; }
.dropdown-group a:not(.dropdown-group-title) { padding-top: 8px; padding-bottom: 8px; font-size: 13px; }
.nav-actions { display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
.language-switch { height: 38px; display: inline-grid; grid-template-columns: repeat(2,34px); align-items: center; padding: 3px; border: 1px solid rgba(0,144,255,.2); border-radius: 20px; background: var(--blue-50); box-shadow: inset 0 0 0 1px rgba(255,255,255,.7); }
.language-switch span,.language-switch a { height: 30px; display: grid; place-items: center; border-radius: 16px; font-size: 11px; font-weight: 800; letter-spacing: .4px; transition: color .25s ease, background .25s ease, transform .25s ease, box-shadow .25s ease; }
@ -288,7 +295,6 @@ main { min-height: 72vh; }
.hero-orbit-two { right: 105px; top: 110px; width: 190px; height: 190px; opacity: .14; animation-direction: reverse; animation-duration: 11s; }
@keyframes orbit { to { transform: rotate(360deg); } }
.hero-copy { position: relative; z-index: 1; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; color: #fff; }
.hero-index { position: absolute; right: 3%; bottom: -24px; color: rgba(255,255,255,.07); font-size: clamp(100px, 14vw, 185px); font-weight: 900; line-height: 1; letter-spacing: -8px; user-select: none; }
.hero-copy > span { display: flex; align-items: center; gap: 12px; color: rgba(255,255,255,.72); font-size: 10px; font-weight: 800; letter-spacing: 4px; }
.hero-copy > span::before { content: ""; width: 38px; height: 2px; background: var(--blue-300); }
.hero-copy h1 { margin: 11px 0 13px; max-width: 900px; font-size: clamp(40px, 5vw, 68px); line-height: 1.06; letter-spacing: -3px; text-shadow: 0 12px 38px rgba(0,0,0,.2); }
@ -310,8 +316,7 @@ main { min-height: 72vh; }
/* Inner content */
.content-page { background: linear-gradient(180deg,#fff 0,var(--blue-50) 100%); padding-top: 76px; }
.content { padding: 96px 0 128px; }
.section-heading { display: grid; grid-template-columns: 62px auto 1fr; align-items: center; gap: 20px; margin-bottom: 52px; }
.section-heading > span { width: 62px; height: 62px; display: grid; place-items: center; color: #fff; border-radius: 18px 4px 18px 4px; background: linear-gradient(145deg,var(--blue-500),var(--blue-800)); box-shadow: 0 12px 28px rgba(8,120,232,.23); font-size: 18px; font-weight: 900; }
.section-heading { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 20px; margin-bottom: 52px; }
.section-heading small { color: var(--blue-600); font-size: 10px; font-weight: 800; letter-spacing: 3px; }
.section-heading h2 { margin: 7px 0 0; font-size: clamp(30px,4vw,46px); letter-spacing: -2px; }
.section-heading i { height: 1px; margin-left: 20px; background: linear-gradient(90deg,var(--line),transparent); }
@ -370,6 +375,81 @@ main { min-height: 72vh; }
.category-filters button { padding: 10px 16px; color: #587087; border: 0; border-radius: 10px; background: var(--blue-50); cursor: pointer; transition: .25s; }
.category-filters button:hover, .category-filters button.active { color: #fff; background: linear-gradient(135deg,var(--blue-500),var(--blue-700)); box-shadow: 0 8px 20px rgba(8,120,232,.2); }
.product-toolbar > span { flex: 0 0 auto; color: var(--muted); font-size: 12px; }
.product-pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 9px; margin: 48px 0 0; }
.product-pagination button { min-width: 42px; height: 42px; padding: 0 12px; color: #526b83; border: 1px solid rgba(8,120,232,.14); border-radius: 11px 3px 11px 3px; background: #fff; box-shadow: 0 8px 22px rgba(4,67,128,.06); cursor: pointer; font-weight: 750; transition: .25s ease; }
.product-pagination button:hover:not(:disabled),.product-pagination button.active { color: #fff; border-color: transparent; background: linear-gradient(145deg,var(--blue-500),var(--blue-800)); box-shadow: 0 10px 25px rgba(8,120,232,.24); transform: translateY(-2px); }
.product-pagination button:disabled { opacity: .35; cursor: not-allowed; }
.purchase-channels { display: grid; grid-template-columns: minmax(260px,.7fr) minmax(0,1.3fr); gap: 46px; align-items: center; margin-top: 68px; padding: 42px 46px; overflow: hidden; color: #fff; border-radius: 28px 7px 28px 7px; background: linear-gradient(135deg,#005eae,#0090ff 68%,#42b5ff); box-shadow: 0 26px 65px rgba(0,88,167,.2); }
.purchase-channel-copy small { color: rgba(255,255,255,.66); font-size: 10px; font-weight: 850; letter-spacing: 3px; }
.purchase-channel-copy h2 { margin: 9px 0 12px; font-size: clamp(30px,3.5vw,44px); }
.purchase-channel-copy p { max-width: 420px; margin: 0; color: rgba(255,255,255,.76); font-size: 14px; line-height: 1.8; }
.purchase-channel-links { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
.purchase-channel-links a { display: grid; grid-template-columns: 48px minmax(0,1fr); gap: 16px; align-items: center; min-height: 118px; padding: 20px; color: var(--ink); border: 1px solid rgba(255,255,255,.5); border-radius: 18px 5px 18px 5px; background: rgba(255,255,255,.94); box-shadow: 0 14px 34px rgba(0,66,126,.16); transition: transform .3s ease, box-shadow .3s ease, background .3s ease; }
.purchase-channel-links a:hover { color: var(--ink); background: #fff; box-shadow: 0 22px 44px rgba(0,57,112,.24); transform: translateY(-5px); }
.purchase-channel-links a > span { width: 48px; height: 48px; display: grid; place-items: center; color: #fff; border-radius: 14px 4px 14px 4px; background: linear-gradient(145deg,var(--blue-500),var(--blue-800)); font-size: 13px; font-weight: 900; }
.purchase-channel-links small { display: block; color: var(--blue-600); font-size: 9px; font-weight: 800; letter-spacing: 1.5px; }
.purchase-channel-links strong { display: block; margin-top: 4px; font-size: 20px; }
.purchase-channel-links b { grid-column: 2; color: var(--blue-700); font-size: 11px; }
.purchase-channels.compact { display: block; margin: 28px 0 18px; padding: 18px; color: var(--ink); border: 1px solid rgba(8,120,232,.12); border-radius: 16px 4px 16px 4px; background: linear-gradient(145deg,#fff,var(--blue-50)); box-shadow: 0 12px 28px rgba(0,83,155,.07); }
.purchase-channels.compact .purchase-channel-copy { margin-bottom: 12px; }
.purchase-channels.compact .purchase-channel-copy > small,.purchase-channels.compact .purchase-channel-copy p { display: none; }
.purchase-channels.compact .purchase-channel-copy h2 { margin: 0; color: var(--ink); font-size: 17px; }
.purchase-channels.compact .purchase-channel-links { gap: 9px; }
.purchase-channels.compact .purchase-channel-links a { grid-template-columns: 36px minmax(0,1fr); gap: 10px; min-height: 76px; padding: 10px 12px; border-color: rgba(8,120,232,.13); box-shadow: none; }
.purchase-channels.compact .purchase-channel-links a > span { width: 36px; height: 36px; border-radius: 10px 3px 10px 3px; font-size: 10px; }
.purchase-channels.compact .purchase-channel-links small { display: none; }
.purchase-channels.compact .purchase-channel-links strong { margin: 0; font-size: 15px; }
.purchase-channels.compact .purchase-channel-links b { font-size: 9px; }
/* Single-page sports business sections */
.sports-grouped-layout { display: grid; grid-template-columns: 210px minmax(0,1fr); gap: 54px; align-items: start; }
.sports-side-nav { position: sticky; top: calc(var(--header-offset) + 92px); z-index: 25; padding: 24px 18px; border: 1px solid rgba(8,120,232,.12); border-radius: 20px 5px 20px 5px; background: rgba(255,255,255,.94); box-shadow: 0 18px 42px rgba(2,62,119,.09); backdrop-filter: blur(16px); }
.sports-side-nav > small { display: block; margin: 0 8px 14px; color: var(--blue-600); font-size: 9px; font-weight: 850; letter-spacing: 2px; line-height: 1.6; }
.sports-side-nav nav { display: grid; gap: 7px; }
.sports-side-nav a { display: grid; grid-template-columns: 28px minmax(0,1fr); gap: 9px; align-items: center; padding: 12px 10px; color: #587087; border-radius: 11px 3px 11px 3px; font-size: 13px; font-weight: 700; transition: .25s ease; }
.sports-side-nav a span { color: var(--blue-400); font-size: 10px; font-weight: 900; }
.sports-side-nav a:hover,.sports-side-nav a.active { color: #fff; background: linear-gradient(145deg,var(--blue-500),var(--blue-800)); box-shadow: 0 10px 24px rgba(8,120,232,.22); transform: translateX(3px); }
.sports-side-nav a:hover span,.sports-side-nav a.active span { color: rgba(255,255,255,.7); }
.sports-grouped-content { min-width: 0; }
.sports-grouped-section { scroll-margin-top: calc(var(--header-offset) + 135px); padding: 12px 0 104px; }
.sports-grouped-section + .sports-grouped-section { padding-top: 92px; border-top: 1px solid var(--line); }
.sports-grouped-heading { max-width: 820px; margin-bottom: 40px; }
.sports-grouped-heading small { color: var(--blue-600); font-size: 10px; font-weight: 850; letter-spacing: 3px; }
.sports-grouped-heading h2 { margin: 10px 0 13px; color: var(--ink); font-size: clamp(32px,4vw,50px); letter-spacing: -2px; }
.sports-grouped-heading p { max-width: 680px; margin: 0; color: var(--muted); font-size: 14px; line-height: 1.85; }
.sports-grouped-articles { margin-top: 0; }
.sports-performance-block,.sports-text-gallery { display: grid; gap: 34px; }
.sports-performance-block > .prose,.sports-text-gallery > .prose { max-width: 860px; padding: 30px 34px; border-left: 4px solid var(--blue-500); border-radius: 0 15px 15px 0; background: #fff; box-shadow: 0 14px 38px rgba(2,62,119,.07); }
.sports-grouped-gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
.sports-grouped-gallery img { width: 100%; height: 290px; object-fit: cover; border-radius: 18px 4px 18px 4px; box-shadow: 0 13px 34px rgba(2,62,119,.1); }
.poster-gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; align-items: stretch; }
.poster-gallery__card { position: relative; min-width: 0; min-height: 420px; padding: 18px; overflow: hidden; border: 1px solid rgba(8,120,232,.12); border-radius: 20px 5px 20px 5px; background: linear-gradient(145deg,#f7fbff,#eaf5ff); box-shadow: 0 13px 34px rgba(2,62,119,.1); cursor: zoom-in; }
.poster-gallery__card img { display: block; width: 100%; height: 100%; max-height: 660px; object-fit: contain; transition: transform .35s ease; }
.poster-gallery__card > span { position: absolute; right: 14px; bottom: 14px; display: inline-flex; align-items: center; gap: 8px; padding: 9px 12px; color: #fff; border-radius: 10px 3px 10px 3px; background: rgba(0,69,134,.82); box-shadow: 0 8px 22px rgba(0,47,91,.22); backdrop-filter: blur(10px); font-size: 11px; font-weight: 750; }
.poster-gallery__card > span b { font-size: 14px; }
.poster-gallery__card:hover img { transform: scale(1.018); }
.poster-lightbox-open { overflow: hidden; }
.poster-lightbox { position: fixed; inset: 0; z-index: 10000; display: grid; place-items: center; color: #fff; outline: none; }
.poster-lightbox__backdrop { position: absolute; inset: 0; background: rgba(0,16,31,.96); backdrop-filter: blur(14px); }
.poster-lightbox__topbar { position: absolute; z-index: 3; top: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 24px; background: linear-gradient(180deg,rgba(0,14,28,.82),transparent); }
.poster-lightbox__topbar > div { min-width: 0; display: grid; gap: 3px; }
.poster-lightbox__topbar strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.poster-lightbox__topbar small { color: rgba(255,255,255,.58); font-size: 11px; }
.poster-lightbox__topbar button,.poster-lightbox__arrow,.poster-lightbox__controls button { display: grid; place-items: center; color: #fff; border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.1); backdrop-filter: blur(12px); cursor: pointer; }
.poster-lightbox__topbar button { flex: 0 0 auto; width: 42px; height: 42px; border-radius: 50%; font-size: 28px; line-height: 1; }
.poster-lightbox__viewport { position: absolute; z-index: 1; inset: 72px 86px 76px; display: grid; place-items: center; overflow: hidden; touch-action: none; }
.poster-lightbox__viewport.is-draggable { cursor: grab; }
.poster-lightbox__viewport.is-dragging { cursor: grabbing; }
.poster-lightbox__viewport img { display: block; flex: 0 0 auto; max-width: none; max-height: none; object-fit: fill; user-select: none; transform-origin: center; transition: transform .16s ease; }
.poster-lightbox__viewport.is-dragging img { transition: none; }
.poster-lightbox__arrow { position: absolute; z-index: 3; top: 50%; width: 48px; height: 66px; border-radius: 14px 4px 14px 4px; transform: translateY(-50%); font-size: 40px; }
.poster-lightbox__arrow--previous { left: 20px; }
.poster-lightbox__arrow--next { right: 20px; }
.poster-lightbox__controls { position: absolute; z-index: 3; left: 50%; bottom: 20px; display: flex; align-items: center; gap: 7px; padding: 7px; border: 1px solid rgba(255,255,255,.12); border-radius: 14px 4px 14px 4px; background: rgba(0,35,69,.72); transform: translateX(-50%); backdrop-filter: blur(14px); }
.poster-lightbox__controls button { min-width: 38px; height: 36px; border-radius: 9px 3px 9px 3px; font-size: 18px; }
.poster-lightbox__controls button:disabled { opacity: .35; cursor: default; }
.poster-lightbox__controls .poster-lightbox__scale { min-width: 68px; font-size: 11px; font-weight: 800; }
.poster-lightbox button:hover:not(:disabled) { background: rgba(8,144,255,.65); }
.card-detail-link { display: block; margin-top: 18px; color: var(--blue-600); font-size: 12px; }
.content-detail-link { display: inline-flex; align-items: center; gap: 18px; margin-top: 20px; padding: 14px 20px; color: #fff; border-radius: 10px; background: linear-gradient(135deg,var(--blue-500),var(--blue-700)); box-shadow: 0 12px 24px rgba(8,120,232,.2); font-size: 13px; font-weight: 700; }
.content-detail-link span { transition: transform .25s; }
@ -570,14 +650,16 @@ main { min-height: 72vh; }
.venue-city-title h3 { margin: 0; color: var(--ink); font-size: 29px; }
.venue-city-title b { margin-left: auto; color: var(--muted); font-size: 11px; font-weight: 600; }
.venue-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 14px; }
.venue-grid article { min-height: 128px; display: grid; grid-template-columns: 32px minmax(0,1fr); gap: 14px; align-items: start; padding: 23px 22px; border: 1px solid rgba(0,144,255,.12); border-radius: 15px 3px 15px 3px; background: #fff; box-shadow: 0 9px 28px rgba(0,83,155,.055); transition: transform .3s ease, border-color .3s ease, box-shadow .3s ease; }
.venue-grid article:hover { transform: translateY(-5px); border-color: rgba(0,144,255,.3); box-shadow: 0 18px 38px rgba(0,83,155,.11); }
.venue-grid > a { min-height: 150px; display: grid; grid-template-columns: 32px minmax(0,1fr); gap: 14px; align-items: start; padding: 23px 22px; border: 1px solid rgba(0,144,255,.12); border-radius: 15px 3px 15px 3px; background: #fff; box-shadow: 0 9px 28px rgba(0,83,155,.055); transition: transform .3s ease, border-color .3s ease, box-shadow .3s ease; }
.venue-grid > a:hover { transform: translateY(-5px); border-color: rgba(0,144,255,.3); box-shadow: 0 18px 38px rgba(0,83,155,.11); }
.venue-pin { position: relative; width: 28px; height: 28px; border: 2px solid var(--blue-600); border-radius: 50% 50% 50% 6px; transform: rotate(-45deg); }
.venue-pin::after { content: ""; position: absolute; left: 8px; top: 8px; width: 8px; height: 8px; border-radius: 50%; background: var(--blue-600); }
.venue-grid h4 { margin: 0 0 10px; color: var(--ink); font-size: 16px; }
.venue-grid p { margin: 0; color: #64788d; font-size: 12px; line-height: 1.75; }
.venue-map-link { display: inline-flex; align-items: center; gap: 8px; margin-top: 14px; color: var(--blue-600); font-size: 11px; font-weight: 800; }
.venue-map-link span { transition: transform .25s ease; }
.venue-grid > a:hover .venue-map-link span { transform: translateX(4px); }
.contact-info-section { color: #fff; background: linear-gradient(135deg,#006fc9,#0090ff 68%,#3facff); }
.contact-info-section .section-heading > span { color: var(--blue-700); background: #fff; box-shadow: 0 12px 30px rgba(0,73,135,.2); }
.contact-info-section .section-heading small { color: rgba(255,255,255,.68); }
.contact-info-section .section-heading h2 { color: #fff; }
.contact-info-section .section-heading i { background: linear-gradient(90deg,rgba(255,255,255,.3),transparent); }
@ -676,9 +758,12 @@ main { min-height: 72vh; }
.dropdown::before, .dropdown-label { display: none; }
.dropdown a { display: inline-block; margin-right: 18px; padding: 9px 0; font-size: 14px; }
.dropdown a::before { display: none; }
.dropdown-mega { width: 100%; padding-left: 18px; }
.dropdown-groups { display: block; }
.dropdown-group { margin: 5px 0 10px; padding: 8px 10px; }
.dropdown .dropdown-group-title { display: block; margin: 0; padding: 9px 0; background: transparent; }
.home-original { padding-top: 70px; }
.page-hero { height: 400px; padding-top: 70px; }
.hero-index { right: 5%; font-size: 130px; }
.scroll-cue { display: none; }
.subnav { justify-content: flex-start; min-height: 64px; overflow-x: auto; padding: 8px 20px; }
.subnav a { min-width: 120px; white-space: nowrap; }
@ -715,6 +800,12 @@ main { min-height: 72vh; }
.product-summary { position: static; }
.detail-copy-grid { grid-template-columns: 1fr; gap: 38px; }
.related-grid { grid-template-columns: repeat(2,1fr); }
.purchase-channels { grid-template-columns: 1fr; gap: 28px; }
.sports-grouped-layout { grid-template-columns: 1fr; gap: 34px; }
.sports-side-nav { top: calc(var(--header-offset) + 64px); padding: 12px; overflow-x: auto; border-radius: 14px 3px 14px 3px; }
.sports-side-nav > small { display: none; }
.sports-side-nav nav { display: flex; width: max-content; min-width: 100%; gap: 7px; }
.sports-side-nav a { flex: 0 0 auto; min-width: 142px; transform: none; }
.editorial-detail { grid-template-columns: 1fr; gap: 35px; }
.editorial-detail aside { position: static; }
}
@ -727,10 +818,8 @@ main { min-height: 72vh; }
.hero-orbit-two { display: none; }
.hero-copy h1 { margin-top: 10px; font-size: 38px; letter-spacing: -2px; }
.hero-copy p { font-size: 13px; line-height: 1.7; }
.hero-index { bottom: -12px; font-size: 92px; letter-spacing: -6px; }
.content { padding: 68px 0 88px; }
.section-heading { grid-template-columns: 48px auto; gap: 14px; margin-bottom: 38px; }
.section-heading > span { width: 48px; height: 48px; border-radius: 13px 3px 13px 3px; font-size: 14px; }
.section-heading { grid-template-columns: minmax(0,auto) 1fr; gap: 14px; margin-bottom: 38px; }
.section-heading i { display: none; }
.section-heading h2 { font-size: 30px; }
.story-layout { padding: 25px 22px; border-radius: 19px 4px 19px 4px; }
@ -784,6 +873,23 @@ main { min-height: 72vh; }
.product-toolbar { align-items: flex-start; flex-direction: column; }
.category-filters { width: 100%; flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
.category-filters button { flex: 0 0 auto; }
.product-pagination { gap: 7px; margin-top: 36px; }
.product-pagination button { min-width: 38px; height: 38px; padding: 0 9px; }
.purchase-channels { margin-top: 48px; padding: 30px 24px; border-radius: 21px 5px 21px 5px; }
.purchase-channel-links { grid-template-columns: 1fr; }
.purchase-channels.compact .purchase-channel-links { grid-template-columns: 1fr; }
.sports-grouped-section { padding-bottom: 72px; }
.sports-grouped-section + .sports-grouped-section { padding-top: 62px; }
.sports-grouped-heading { margin-bottom: 30px; }
.sports-grouped-gallery { grid-template-columns: 1fr; }
.sports-grouped-gallery img { height: 230px; }
.poster-gallery { grid-template-columns: 1fr; }
.poster-gallery__card { min-height: 390px; padding: 12px; }
.poster-lightbox__topbar { padding: 13px 15px; }
.poster-lightbox__viewport { inset: 68px 12px 78px; }
.poster-lightbox__arrow { top: auto; bottom: 18px; width: 42px; height: 42px; border-radius: 50%; transform: none; font-size: 30px; }
.poster-lightbox__arrow--previous { left: 14px; }
.poster-lightbox__arrow--next { right: 14px; }
.detail-hero, .detail-hero-inner { min-height: 430px; }
.breadcrumbs { top: 24px; left: 0; }
.detail-hero h1 { font-size: 39px; letter-spacing: -2px; }

View File

@ -4,8 +4,8 @@
<div class="hero-shade"></div><div class="hero-grid"></div>
<div class="hero-orbit hero-orbit-one"></div><div class="hero-orbit hero-orbit-two"></div>
<div class="hero-copy container">
<div class="hero-index">08</div><span>CONTACT US</span><h1>联系我们</h1>
<p>连接跃动社媒矩阵、业务合作网络与全国线下运动场馆。</p>
<span>CONTACT US</span><h1>联系我们</h1>
<p>连接跃动社媒矩阵、业务合作网络与品牌服务团队。</p>
<div class="hero-line"><i></i><b>CONNECT WITH YUEDONG</b></div>
</div>
</section>
@ -17,7 +17,7 @@
<div class="contact-content">
<section id="social" class="contact-section contact-social-section">
<div class="container">
<div v-reveal class="section-heading"><span>01</span><div><small>SOCIAL &amp; BUSINESS</small><h2>社媒与业务合作</h2></div><i></i></div>
<div v-reveal class="section-heading"><div><small>SOCIAL &amp; BUSINESS</small><h2>社媒与业务合作</h2></div><i></i></div>
<div class="contact-lead-grid">
<div v-reveal class="contact-intro">
<span>跃动全网 · 玩转跳绳</span>
@ -44,24 +44,9 @@
</div>
</section>
<section id="venues" class="contact-section venue-section">
<div class="container">
<div v-reveal class="section-heading"><span>02</span><div><small>VENUE NETWORK</small><h2>线下场馆地址</h2></div><i></i></div>
<p v-reveal class="venue-summary">场馆按城市平铺展示,便于快速查找附近的跃动运动馆。门店信息可能调整,到访前可通过第三部分联系方式确认。</p>
<div v-for="city in venueGroups" :key="city.city" class="venue-city">
<div v-reveal class="venue-city-title"><span>{{ city.en }}</span><h3>{{ city.city }}</h3><b>{{ city.venues.length }} 家场馆</b></div>
<div class="venue-grid">
<article v-for="(venue, index) in city.venues" :key="venue.name" v-reveal :style="{ '--delay': `${(index % 4) * 55}ms` }">
<span class="venue-pin"></span><div><h4>{{ venue.name }}</h4><p>{{ venue.address }}</p></div>
</article>
</div>
</div>
</div>
</section>
<section id="contact-info" class="contact-section contact-info-section">
<div class="container">
<div v-reveal class="section-heading light"><span>03</span><div><small>BUSINESS CONTACT</small><h2>联系方式</h2></div><i></i></div>
<div v-reveal class="section-heading light"><div><small>BUSINESS CONTACT</small><h2>联系方式</h2></div><i></i></div>
<div class="contact-card-grid">
<a v-for="(contact, index) in contacts" :key="contact.phone" v-reveal :href="`tel:${contact.phone}`" :style="{ '--delay': `${index * 70}ms` }">
<span>0{{ index + 1 }}</span><small>{{ contact.en }}</small><h3>{{ contact.service }}</h3><p>{{ contact.name }}</p><b>{{ contact.phone }}</b><i>拨打电话 →</i>
@ -80,7 +65,6 @@ import { useRoute } from 'vue-router';
const route = useRoute();
const sectionNav = [
{ key: 'social', label: '社媒与合作' },
{ key: 'venues', label: '场馆地址' },
{ key: 'contact-info', label: '联系方式' }
];
const activeKey = computed(() => sectionNav.some((item) => item.key === route.params.section) ? route.params.section : 'social');
@ -100,57 +84,6 @@ const capabilities = [
{ title: '装备服务', en: 'EQUIPMENT', description: '专业跳绳、智能装备及团体采购定制。' }
];
const venueGroups = [
{ city: '上海', en: 'SHANGHAI', venues: [
{ name: '杨浦翔殷馆', address: '上海市杨浦区翔殷路260号' },
{ name: '杨浦新江湾运动健康中心馆', address: '上海市杨浦区国浩路701号201室' },
{ name: '宝山高境三邻桥馆', address: '上海市宝山区江杨南路485号三邻桥体育文化园11号101B室' },
{ name: '宝山体育中心馆', address: '上海市宝山区永清路700号体育中心内' },
{ name: '闵行七宝宝龙广场馆', address: '上海市闵行区漕宝路3299弄七宝宝龙广场3楼' },
{ name: '浦东金桥馆', address: '上海市浦东新区金藏路1296弄151号2楼2027室' },
{ name: '静安盛源生活广场馆', address: '上海市静安区柳营路777号盛源生活广场2楼' },
{ name: '静安889GLO广场馆', address: '上海市静安区万航渡路889号889广场3楼' },
{ name: '闵行力波馆', address: '上海市闵行区虹梅南路379弄27号力波九坊' },
{ name: '浦东恒生万鹂广场馆', address: '上海市浦东新区芳甸路115弄恒生万鹂广场' },
{ name: '虹口瑞虹星星堂馆', address: '上海市虹口区天宝路280弄瑞虹天地星星堂' },
{ name: '普陀曹杨复悦荟馆', address: '上海市普陀区曹杨路1888号复悦荟3楼' },
{ name: '青浦区跃动青少儿运动健康促进中心', address: '上海市青浦区淀山湖路535号4楼' }
] },
{ city: '北京', en: 'BEIJING', venues: [
{ name: '西城官园馆', address: '北京市西城区西直门南小街2号楼B1-103' },
{ name: '丰台草桥馆', address: '北京市丰台区草桥欣园一区5号楼2楼260室' },
{ name: '西城马连道馆', address: '北京市西城区马连道街华睦大厦' },
{ name: '东城崇文门馆', address: '北京市东城区崇文门区域' },
{ name: '西城西便门馆', address: '北京市西城区西便门区域' },
{ name: '朝阳大郊亭馆', address: '北京市朝阳区大郊亭华腾新天地运动中心' }
] },
{ city: '天津', en: 'TIANJIN', venues: [
{ name: '天津南开馆', address: '天津市南开区宾川道507号' },
{ name: '天津和平馆', address: '天津市和平区人民体育馆区域' }
] },
{ city: '广州', en: 'GUANGZHOU', venues: [
{ name: '天河林和东馆', address: '广州市天河区林和东路218号2楼自编03室' },
{ name: '海珠广纸天地荟馆', address: '广州市海珠区工业大道广纸天地荟207-208' }
] },
{ city: '徐州', en: 'XUZHOU', venues: [
{ name: '经开淮海环球港馆', address: '徐州市经开区淮海环球港L2096' },
{ name: '鼓楼金地商都馆', address: '徐州市鼓楼区中山北路6号金地商都' }
] },
{ city: '合肥', en: 'HEFEI', venues: [
{ name: '庐阳华润橡树湾馆', address: '合肥市庐阳区华润橡树湾商业区' },
{ name: '包河馆', address: '合肥市包河区安徽省运动健康促进中心2楼' }
] },
{ city: '成都', en: 'CHENGDU', venues: [
{ name: '武侯莱蒙都会馆', address: '成都市武侯区二环路南四段51号莱蒙都会商场4楼' }
] },
{ city: '加盟场馆', en: 'FRANCHISE VENUES', venues: [
{ name: '松江大学城馆', address: '上海市松江区梅家浜路1501号3楼' },
{ name: '广西南宁馆', address: '南宁市青秀区星湖路1号' },
{ name: '江西信丰馆', address: '赣州市信丰县体育中心内' },
{ name: '江西上饶馆', address: '上饶市广信区旭日街道武夷山大道博华年华北区2栋111-113' }
] }
];
const contacts = [
{ service: '赛事活动', en: 'EVENTS', name: '郭先生', phone: '16621102107' },
{ service: '演艺服务', en: 'PERFORMANCE', name: '李先生', phone: '15201737732' },

View File

@ -33,9 +33,17 @@ const detail = computed(() => {
if (item) return { title: item.title || item.alt || source.title, body: item.body || source.body || [page.value.summary], gallery: source.gallery || [] };
return { title: source.title, body: source.body || source.articles?.flatMap((entry) => entry.body) || [page.value.summary], gallery: source.gallery || [] };
});
const backPath = computed(() => ['technology', 'health', 'ygjrc'].includes(route.params.page)
? `/${route.params.page}`
: `/${route.params.page}/${route.params.section}`);
const backPath = computed(() => {
if (route.params.page === 'sports') {
const competitionSections = ['event-recruitment', 'event-news', 'performances'];
const venueSections = ['venue-intro', 'champion-class', 'camps', 'venue-address'];
if (competitionSections.includes(route.params.section)) return `/sports/competition#${route.params.section}`;
if (venueSections.includes(route.params.section)) return `/sports/venues#${route.params.section}`;
}
return ['technology', 'health', 'ygjrc'].includes(route.params.page)
? `/${route.params.page}`
: `/${route.params.page}/${route.params.section}`;
});
const heroStyle = computed(() => page.value?.hero?.src ? { backgroundImage: `url("${page.value.hero.src}")` } : {});
const unreadableImageName = /\uFFFD|锟斤拷|鈥|銆|鈫|馃|鍥剧墖|璺冨姩|鍏充簬|鏂伴椈|浜у搧|绀惧獟|璇︽儏/;
const genericImageName = /^(?:微信图片|图片|image|img|dsc|screenshot|截图|未命名|幻灯片|slide|photo)[-_((\s]?\d*/i;

View File

@ -6,43 +6,59 @@
<div class="hero-orbit hero-orbit-one"></div>
<div class="hero-orbit hero-orbit-two"></div>
<div class="hero-copy container">
<div class="hero-index">{{ String(pageNumber).padStart(2, '0') }}</div>
<span>{{ page.eyebrow }}</span><h1>{{ page.title }}</h1><p>{{ page.summary }}</p>
<div class="hero-line"><i></i><b>EXPLORE YUEDONG</b></div>
</div>
<div class="scroll-cue"><span></span>SCROLL</div>
</section>
<nav v-if="page.tabs" class="subnav" aria-label="栏目导航">
<RouterLink v-for="tab in page.tabs" :key="tab.key" :to="basePath + '/' + tab.key" :class="{ active: tab.key === activeKey }">{{ tab.label }}</RouterLink>
<RouterLink v-for="tab in page.tabs" :key="tab.key" :to="basePath + '/' + tab.key" :class="{ active: tab.key === activeTabKey }">{{ tab.label }}</RouterLink>
</nav>
<section class="content container">
<div v-reveal class="section-heading"><span>{{ String(pageNumber).padStart(2, '0') }}</span><div><small>{{ page.eyebrow }}</small><h2>{{ content.title }}</h2></div><i></i></div>
<div v-reveal class="section-heading"><div><small>{{ page.eyebrow }}</small><h2>{{ content.title }}</h2></div><i></i></div>
<template v-if="content.products">
<div class="product-toolbar">
<div class="category-filters">
<button v-for="category in productCategories" :key="category" :class="{ active: selectedCategory === category }" @click="selectedCategory = category">{{ category }}</button>
</div>
<span>共 {{ visibleProducts.length }} 款产品</span>
<span>共 {{ visibleProducts.length }} 款产品 · 第 {{ currentPage }} / {{ totalPages }} 页</span>
</div>
<div class="product-grid">
<RouterLink v-for="(product, index) in visibleProducts" :key="product.id" v-reveal class="product-card" :to="`/products/detail/${product.id}`" :style="{ '--delay': `${(index % 9) * 45}ms` }">
<div class="card-index">{{ String(index + 1).padStart(2, '0') }}</div>
<RouterLink v-for="(product, index) in paginatedProducts" :key="product.id" v-reveal class="product-card" :to="`${productDetailBase}/${product.id}`" :style="{ '--delay': `${index * 45}ms` }">
<div class="card-index">{{ String((currentPage - 1) * productsPerPage + index + 1).padStart(2, '0') }}</div>
<img v-if="product.images?.[0]" :src="product.images[0]" :alt="product.name" loading="lazy" />
<div class="product-placeholder" v-else>{{ product.category.slice(0, 1) }}</div>
<div><small>{{ product.category }}</small><h3>{{ product.name || product.category }}</h3><p>{{ product.description }}</p><b class="card-detail-link">查看产品详情 →</b></div>
</RouterLink>
</div>
<nav v-if="totalPages > 1" class="product-pagination" aria-label="产品分页">
<button type="button" :disabled="currentPage === 1" aria-label="上一页" @click="goToProductPage(currentPage - 1)">←</button>
<button
v-for="pageItem in totalPages"
:key="pageItem"
type="button"
:class="{ active: pageItem === currentPage }"
:aria-current="pageItem === currentPage ? 'page' : undefined"
:aria-label="`第 ${pageItem} 页`"
@click="goToProductPage(pageItem)"
>{{ pageItem }}</button>
<button type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="goToProductPage(currentPage + 1)">→</button>
</nav>
<PurchaseChannels />
</template>
<SportsGroupedSection
v-else-if="pageKey === 'sports' && ['competition', 'venues'].includes(activeKey)"
:mode="activeKey"
/>
<div v-else-if="content.articles" class="article-list">
<RouterLink v-for="(article, index) in content.articles" :key="article.title" v-reveal :to="detailPath(index)" :style="{ '--delay': `${index * 90}ms` }">
<div class="article-no">0{{ index + 1 }}</div><div><h3>{{ article.title }}</h3><p v-for="line in article.body.slice(0, 3)" :key="line">{{ line }}</p></div>
</RouterLink>
</div>
<SportsHallSection v-else-if="pageKey === 'education' && activeKey === 'venues'" />
<div v-else-if="content.timeline?.length" class="timeline-page">
<div v-if="content.body?.length" v-reveal class="story-layout timeline-intro">
<div class="prose"><p v-for="line in content.body" :key="line">{{ line }}</p></div>
@ -89,11 +105,11 @@
<img v-if="content.gallery?.[0]" :src="content.gallery[0].src" :alt="content.gallery[0].alt" loading="lazy" />
</div>
<div v-if="content.cards" class="card-grid">
<RouterLink v-for="(card, i) in content.cards" :key="card.title || card.alt" v-reveal :to="detailPath(i)" :style="{ '--delay': `${i * 80}ms` }"><span>0{{ i + 1 }}</span><h3>{{ card.title || card.alt }}</h3><p v-if="card.body">{{ card.body[0] }}</p><b>查看详情 →</b></RouterLink>
<div v-if="content.cards && !hideVenueTrailingContent" class="card-grid">
<RouterLink v-for="(card, i) in content.cards" :key="card.title || card.alt" v-reveal :to="card.to || detailPath(i)" :style="{ '--delay': `${i * 80}ms` }"><span>0{{ i + 1 }}</span><h3>{{ card.title || card.alt }}</h3><p v-if="card.body">{{ card.body[0] }}</p><b>查看详情 →</b></RouterLink>
</div>
<div v-if="content.gallery?.length > 1 && pageKey !== 'ygjrc' && pageKey !== 'health'" class="gallery">
<div v-if="content.gallery?.length > 1 && pageKey !== 'ygjrc' && pageKey !== 'health' && !hideVenueTrailingContent" class="gallery">
<figure v-for="(image, index) in content.gallery.slice(1, 13)" :key="image.src" v-reveal :style="{ '--delay': `${(index % 3) * 80}ms` }"><img :src="image.src" :alt="imageLabel(image)" loading="lazy" /><figcaption v-if="imageLabel(image)"><span>{{ String(index + 1).padStart(2, '0') }}</span>{{ imageLabel(image) }}</figcaption></figure>
</div>
@ -110,20 +126,37 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { pages } from '../data/page-content.js';
import { ambassadorsImage, productAmbassadors, ygjrcMembers } from '../data/ygjrc-members.js';
import SportsHallSection from '../components/SportsHallSection.vue';
import HealthSection from '../components/HealthSection.vue';
import PurchaseChannels from '../components/PurchaseChannels.vue';
import SportsGroupedSection from '../components/SportsGroupedSection.vue';
const route = useRoute();
const pageKey = computed(() => route.meta.page);
const page = computed(() => pages[pageKey.value]);
const activeKey = computed(() => route.params.section || 'default');
const activeKey = computed(() => route.params.section || page.value?.tabs?.[0]?.key || 'default');
const content = computed(() => page.value?.sections[activeKey.value] || page.value?.sections.default || Object.values(page.value?.sections || {})[0]);
const basePath = computed(() => ({ about: '/about', events: '/events', products: '/products', education: '/education', contact: '/contact' })[pageKey.value] || '');
const hideVenueTrailingContent = computed(() => pageKey.value === 'sports' && activeKey.value === 'venues');
const activeTabKey = computed(() => content.value?.parent || activeKey.value);
const basePath = computed(() => ({ about: '/about', sports: '/sports', factory: '/factory', contact: '/contact' })[pageKey.value] || '');
const productDetailBase = computed(() => pageKey.value === 'sports' ? '/sports/equipment/detail' : '/products/detail');
const heroStyle = computed(() => page.value?.hero?.src ? { backgroundImage: `url("${page.value.hero.src}")` } : {});
const pageNumber = computed(() => ({ about: 1, events: 2, products: 3, education: 4, technology: 5, health: 6, ygjrc: 7, contact: 8 })[pageKey.value] || 0);
const selectedCategory = ref('全部');
const productsPerPage = 6;
const currentPage = ref(1);
const productCategories = computed(() => ['全部', ...new Set((content.value?.products || []).map((product) => product.category))]);
const visibleProducts = computed(() => selectedCategory.value === '全部' ? content.value.products : content.value.products.filter((product) => product.category === selectedCategory.value));
const visibleProducts = computed(() => {
const products = content.value?.products || [];
return selectedCategory.value === '全部' ? products : products.filter((product) => product.category === selectedCategory.value);
});
const totalPages = computed(() => Math.max(1, Math.ceil(visibleProducts.value.length / productsPerPage)));
const paginatedProducts = computed(() => {
const start = (currentPage.value - 1) * productsPerPage;
return visibleProducts.value.slice(start, start + productsPerPage);
});
const goToProductPage = (pageNumber) => {
currentPage.value = Math.min(totalPages.value, Math.max(1, pageNumber));
document.querySelector('.product-toolbar')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const unreadableImageName = /\uFFFD|锟斤拷|鈥|銆|鈫|馃|鍥剧墖|璺冨姩|鍏充簬|鏂伴椈|浜у搧|绀惧獟|璇︽儏/;
const genericImageName = /^(?:微信图片|图片|image|img|dsc|screenshot|截图|未命名|幻灯片|slide|photo)[-_((\s]?\d*/i;
const imageLabel = (image) => {
@ -131,7 +164,11 @@ const imageLabel = (image) => {
if (!label || unreadableImageName.test(label) || genericImageName.test(label) || /^[\d\s._()()-]+$/.test(label)) return '';
return label;
};
watch(activeKey, () => { selectedCategory.value = '全部'; });
watch(activeKey, () => {
selectedCategory.value = '全部';
currentPage.value = 1;
});
watch(selectedCategory, () => { currentPage.value = 1; });
const detailPath = (index) => {
const suffix = Number.isInteger(index) ? `/${index}` : '';
return `/detail/${pageKey.value}/${activeKey.value}${suffix}`;

View File

@ -12,7 +12,7 @@
<p>以跳绳为核心,连接专业装备、国际赛事、教育培训、智慧科技与运动健康,让每一次跃起都更有力量。</p>
<div class="home-hero-actions">
<RouterLink class="home-primary-button" to="/about/brand-story">探索跃动 <span>→</span></RouterLink>
<RouterLink class="home-ghost-button" to="/products/categories">查看产品装备</RouterLink>
<RouterLink class="home-ghost-button" to="/sports/equipment">查看跃动装备</RouterLink>
</div>
<div class="home-stats">
<div><strong>2009</strong><span>品牌创立</span></div><div><strong>30+</strong><span>全国运动馆</span></div><div><strong>100+</strong><span>专业教练</span></div><div><strong>GLOBAL</strong><span>全球业务网络</span></div>
@ -60,9 +60,9 @@
<section class="home-products">
<div v-reveal class="container home-section-head light"><div><span>FEATURED EQUIPMENT</span><h2>精选产品装备</h2></div><RouterLink to="/products/categories">查看全部 82 款产品 →</RouterLink></div>
<div v-reveal class="container home-section-head light"><div><span>FEATURED EQUIPMENT</span><h2>精选跃动装备</h2></div><RouterLink to="/sports/equipment">查看全部 82 款产品 →</RouterLink></div>
<div class="container home-product-grid">
<RouterLink v-for="(product,index) in featuredProducts" :key="product.id" v-reveal :to="`/products/detail/${product.id}`" :style="{ '--delay': `${index * 80}ms` }">
<RouterLink v-for="(product,index) in featuredProducts" :key="product.id" v-reveal :to="`/sports/equipment/detail/${product.id}`" :style="{ '--delay': `${index * 80}ms` }">
<div class="home-product-image"><img :src="product.images[0]" :alt="product.name" /><span>0{{ index + 1 }}</span></div>
<small>{{ product.category }}</small><h3>{{ product.name }}</h3><b>查看产品详情 ↗</b>
</RouterLink>
@ -71,7 +71,7 @@
<section class="home-news">
<div v-reveal class="container home-section-head"><div><span>EVENTS & NEWS</span><h2>赛事资讯</h2></div><RouterLink to="/events/news">更多赛事动态 →</RouterLink></div>
<div v-reveal class="container home-section-head"><div><span>EVENTS & NEWS</span><h2>竞赛演艺</h2></div><RouterLink to="/sports/competition#event-news">更多赛事动态 →</RouterLink></div>
<div class="container home-news-grid">
<RouterLink v-for="(article,index) in eventArticles" :key="article.title" v-reveal :to="article.to" :style="{ '--delay': `${index * 90}ms` }">
<div class="home-news-image"><img :src="article.image" :alt="article.title" /><span>EVENT 0{{ index + 1 }}</span></div>
@ -110,7 +110,7 @@ const previousHonor = () => { activeHonor.value = (activeHonor.value - 1 + honor
const nextHonor = () => { activeHonor.value = (activeHonor.value + 1) % honors.length; startHonorRotation(); };
onMounted(startHonorRotation);
onBeforeUnmount(stopHonorRotation);
const eventArticles = pages.events.sections.news.articles.slice(0, 3).map((article, index) => ({ ...article, image: pages.events.sections.news.gallery[index]?.src || pages.events.hero?.src, to: `/detail/events/news/${index}` }));
const eventArticles = pages.sports.sections['event-news'].articles.slice(0, 3).map((article, index) => ({ ...article, image: pages.sports.sections['event-news'].gallery[index]?.src || pages.sports.hero?.src, to: `/detail/sports/event-news/${index}` }));
const vReveal = {
mounted(el) {

View File

@ -3,7 +3,7 @@
<section class="detail-hero">
<div class="detail-hero-grid"></div>
<div class="container detail-hero-inner">
<nav class="breadcrumbs"><RouterLink to="/">首页</RouterLink><span>/</span><RouterLink to="/products/categories">产品装备</RouterLink><span>/</span><b>{{ product.category }}</b></nav>
<nav class="breadcrumbs"><RouterLink to="/">首页</RouterLink><span>/</span><RouterLink to="/sports/equipment">跃动装备</RouterLink><span>/</span><b>{{ product.category }}</b></nav>
<div class="detail-kicker">PRODUCT DETAIL · {{ product.id.toUpperCase() }}</div>
<h1>{{ product.name }}</h1>
<p>{{ product.category }} · 来源于《跳绳产品分类-7类资料》{{ product.sheet }}工作表第 {{ product.row }} 行</p>
@ -39,6 +39,7 @@
<h2>{{ product.name }}</h2>
<div class="model-lines"><span v-for="line in modelLines" :key="line">{{ line }}</span></div>
<p>{{ descriptionLead }}</p>
<PurchaseChannels compact />
<a href="#specifications" class="detail-primary-action">查看完整规格参数 <span>↓</span></a>
</div>
</section>
@ -61,7 +62,7 @@
<div class="container">
<div class="detail-section-title"><small>RELATED PRODUCTS</small><h2>同类产品</h2></div>
<div class="related-grid">
<RouterLink v-for="item in related" :key="item.id" :to="`/products/detail/${item.id}`">
<RouterLink v-for="item in related" :key="item.id" :to="`/sports/equipment/detail/${item.id}`">
<img v-if="item.images[0]" :src="item.images[0]" :alt="item.name" /><div v-else class="related-placeholder">{{ item.category.slice(0, 1) }}</div>
<span>{{ item.category }}</span><h3>{{ item.name }}</h3><b>查看详情 →</b>
</RouterLink>
@ -75,6 +76,7 @@
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import products from '../data/product-details.json';
import PurchaseChannels from '../components/PurchaseChannels.vue';
const route = useRoute();
const product = computed(() => products.find((item) => item.id === route.params.id));