update:打包上传&架构修改
This commit is contained in:
parent
1fbbb12f6a
commit
201c85b1d9
32
OSS-DEPLOY.md
Normal file
32
OSS-DEPLOY.md
Normal 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`;上传和本地路径检查仍会正常执行。
|
||||
@ -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
137
scripts/deploy-oss.ps1
Normal 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
|
||||
}
|
||||
190
src/components/PosterGallery.vue
Normal file
190
src/components/PosterGallery.vue
Normal 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>
|
||||
42
src/components/PurchaseChannels.vue
Normal file
42
src/components/PurchaseChannels.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
177
src/components/SportsGroupedSection.vue
Normal file
177
src/components/SportsGroupedSection.vue
Normal 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>
|
||||
63
src/components/VenueAddressSection.vue
Normal file
63
src/components/VenueAddressSection.vue
Normal 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>
|
||||
@ -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
82
src/data/venue-groups.js
Normal 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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
130
src/style.css
130
src/style.css
@ -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; }
|
||||
|
||||
@ -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 & BUSINESS</small><h2>社媒与业务合作</h2></div><i></i></div>
|
||||
<div v-reveal class="section-heading"><div><small>SOCIAL & 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' },
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user