update
Some checks failed
deploy / deploy (push) Has been cancelled

This commit is contained in:
toom1996
2026-08-15 23:18:18 +08:00
parent 58f9f95f8e
commit 31cb7a125a
2 changed files with 313 additions and 573 deletions

View File

@ -3,597 +3,343 @@ import Layout from "../layouts/Layout.astro"
import ComingSoon from "../components/ComingSoon.astro"
import { getShows, seriesLabel } from "../lib/data"
// 默认 tab(Shows)走 SSR:构建期从接口预取真实数据与多图,首屏立即可见。
// 其余 tab(Backstage / Events / Street)点击时才由前端请求接口动态渲染,避免一次性拉全量拖慢首屏。
// SSR 预取走秀数据
const shows = await getShows(20)
// 横向滚动走秀画廊:扁平化所有 show 的图片(cover + gallery 去重),
// 每张卡片 = 一个文章(show)的封面,不展开 gallery,避免同一文章重复出现。
type GalleryItem = { src: string; brand: string; articleId: number; year: number; title: string; collection_type?: string }
type GalleryItem = {
src: string;
brand: string;
articleId: number;
year: number;
title: string;
collection_type?: string
}
const galleryItems: GalleryItem[] = []
for (const s of shows) {
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
if (!s.img) continue
galleryItems.push({
src: s.img,
brand: s.brand,
articleId: s.id,
year: s.year,
title: s.title,
collection_type: s.collection_type,
})
}
---
<Layout>
<!-- 二级大字号分类(标签页):仅「发布会」上线,其余标注 Coming Soon -->
<div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="backstage" data-active="true">A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="shows" data-soon="true">B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="events" data-soon="true">C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap" data-tab="street" data-soon="true">D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<div x-data="{ activeTab: 'backstage' }" class="flex flex-col min-h-screen">
<!-- 二级分类标签页 -->
<div id="tabbar" class="mt-16 md:mt-24">
<ul class="flex items-center gap-6 list-none p-0 m-0 overflow-x-auto">
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'backstage' }"
@click="activeTab = 'backstage'"
>
A
<span class="text-xs uppercase tracking-widest bg-black text-white px-2 py-0.5 rounded-full font-mono font-bold align-super">Live</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'shows' }"
@click="activeTab = 'shows'"
>
B
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
C
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
D
<span class="text-xs uppercase tracking-widest bg-zinc-200 text-zinc-600 px-2 py-0.5 rounded-full font-mono font-bold align-super">Soon</span>
</li>
<a href="/brands" class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap hover:opacity-80">E</a>
</ul>
</div>
<!-- 中间内容展示区:标签内容区,点击标签仅局部更新此区域 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活,SSR 预渲染真实走秀内容) -->
<div class="panel" data-panel="backstage" data-show="true" data-loaded="true">
<!-- 区块标题 -->
<div class="sec-head">
<div>
<h2 class="sec-title">RUNWAY ARCHIVE</h2>
<p class="sec-sub">走秀档案 · 精选最新上架的秀场</p>
</div>
<a href="/shows" class="sec-more">全部走秀 →</a>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="sec-head">
<div>
<h2 class="sec-title">RUNWAY ARCHIVE</h2>
<p class="sec-sub">走秀档案 · 精选最新上架的秀场</p>
</div>
<a href="/shows" class="sec-more">全部走秀 →</a>
</div>
<!-- 横向滚动画廊:左右滑动浏览;图片加载前骨架占位防偏移 -->
{galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<div class="hs-img-box">
<div class="hs-skel" aria-hidden="true"></div>
<span class="hs-spin" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" /></svg>
</span>
<img src={g.src} alt={g.brand} loading={i < 4 ? "eager" : "lazy"} decoding="async" />
<span class="hs-caption">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">{g.year ? g.year : ""}{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}</span>
</span>
</div>
</a>
</div>
))}
</div>
</div>
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div class="hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Previous</div>
<div class="hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Next</div>
</div>
</div>
</section>
)}
<!-- 横向滚动画廊 -->
{
galleryItems.length > 0 && (
<section class="hs-section">
<div class="hs-stage">
<div class="swiper hs-swiper" id="hsSwiper">
<div class="swiper-wrapper">
{
galleryItems.map((g, i) => (
<div class="swiper-slide hs-slide">
<a class="hs-item" href={`/article?id=${g.articleId}`} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 -->
<div
class="hs-img-box"
x-data="{ loaded: false, error: false }"
:class="{ 'is-loaded': loaded || error }"
>
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="hs-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="hs-ring" />
</svg>
</span>
<img
src={g.src}
alt={g.brand}
loading={i < 4 ? "eager" : "lazy"}
decoding="async"
:class="{ 'is-error': error }"
@load="loaded = true; $dispatch('img-ready')"
@error="error = true; $dispatch('img-ready')"
/>
<span class="hs-caption">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">
{g.year ? g.year : ""}
{g.collection_type ? " · " + seriesLabel(g.collection_type) : ""}
</span>
</span>
</div>
</a>
</div>
))
}
</div>
</div>
<div class="hs-controls flex justify-between items-center text-[13px] font-bold">
<div class="text-[16px] hs-arrow hs-prev cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Previous</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">Next</div>
</div>
</div>
</section>
)
}
</div>
</div>
<!-- 灵感 面板 -->
<div class="panel" x-show="activeTab === 'shows'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 灵感 面板(暂未上线) -->
<div class="panel" data-panel="shows" data-show="false" data-loaded="true">
<ComingSoon module="灵感" moduleZh="Inspiration" note="灵感板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 社媒 面板(暂未上线) -->
<div class="panel" data-panel="events" data-show="false" data-loaded="true">
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<!-- 街拍 面板(暂未上线) -->
<div class="panel" data-panel="street" data-show="false" data-loaded="true">
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
<!-- 街拍 面板 -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
</main>
</div>
</Layout>
<style is:global>
@reference "tailwindcss";
/* 卡片及缩略图样式改为全局(is:global):默认 Shows 走 SSR 由 Astro 作用域正常生效,
但其余 tab 的卡片由前端 JS 在运行时注入 DOM,缺 data-astro-cid 属性,
原组件作用域样式无法命中。改为全局后所有 tab 的卡片展示完全一致(3:4)。 */
<style>
@reference "tailwindcss";
/* 标签配色:沿用你原来的 #bbb / #666 hover / 黑 激活 */
.tab { @apply text-[#bbb] transition-colors duration-200 no-underline; }
.tab:hover { @apply text-[#666]; }
.tab[data-active="true"] { @apply text-black; }
.tab[data-active="true"]:hover { @apply text-black; }
:root { --brand: #cf5a44; }
/* 面板:仅显示当前标签对应的内容区,局部切换 */
.panel { @apply hidden; }
.panel[data-show="true"] {
@apply block;
animation: tabfade 0.3s ease;
}
@keyframes tabfade {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
/* 标签基础与激活样式 */
.tab {
@apply relative pb-[6px] text-[#bbb] transition-colors duration-200 no-underline;
}
.tab:hover { @apply text-[#666]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-in-out;
}
.tab.is-active { @apply text-black; }
.tab.is-active::after { @apply scale-x-100; }
/* (.js 门控的淡入与 is-error 占位规则已移至下方 <style is:global>,避免组件作用域破坏 .js 选择器) */
:root { --brand: #cf5a44; }
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* 标签页激活态:品牌色下划线动画 */
.tab { @apply relative pb-[6px]; }
.tab::after {
content: "";
@apply absolute left-0 right-0 bottom-0 h-[2px] bg-[var(--brand)] scale-x-0 origin-left transition-transform duration-[250ms] ease-[ease];
}
.tab[data-active="true"]::after { @apply scale-x-100; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* 懒加载占位骨架卡 */
.skel-card { @apply border border-[#ececec] rounded-[6px] overflow-hidden bg-white; }
.skel-main {
@apply aspect-[3/4] bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%; animation: img-shimmer 1.3s ease-in-out infinite;
}
.skel-line {
@apply h-[12px] mt-[10px] mx-[12px] rounded-[3px] bg-[#f0f0f0];
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%; animation: img-shimmer 1.3s ease-in-out infinite;
}
.skel-line.short { @apply w-1/2; }
/* 横向画廊样式 */
.hs-section { @apply mb-9; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none;
mix-blend-mode: difference;
color: #fff;
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
/* 区块标题 */
.sec-head { @apply flex items-end justify-between mt-2 mb-6 pb-4 border-b border-[#ececec]; }
.sec-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111]; }
.sec-sub { @apply mt-2 text-[12px] uppercase tracking-[.14em] text-[#999] font-mono; }
.sec-more { @apply text-[12px] uppercase tracking-[.14em] font-bold no-underline text-[#111] transition-opacity duration-200; }
.sec-more:hover { @apply opacity-60; }
/* Swiper 尺寸规整 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px);
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; }
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
/* 横向滚动画廊(Runway Gallery):水平 flex 容器,每张图按统一卡片尺寸展示、
左右滑动浏览。图片用 object-fit:cover 适配卡片框(不再因原图宽高比过宽);
加载前骨架 shimmer 占位,加载完图片淡入、骨架淡出。 */
.hs-section { @apply mb-9; }
.hs-head {
@apply flex items-end justify-between gap-4 pb-4 mb-4 border-b border-[#ececec];
}
.hs-title { @apply font-serif text-[28px] leading-none font-extrabold text-[#111] m-0 tracking-[-.01em]; }
.hs-sub { @apply font-mono text-[12px] uppercase tracking-[.14em] text-[#999] mt-2 mb-0; }
.hs-stage { @apply relative; }
.hs-controls {
position: absolute;
top: 50%;
left: 0;
right: 0;
transform: translateY(-50%);
z-index: 6;
padding: 0 8px;
pointer-events: none; /* 容器不挡拖拽,仅按钮可点 */
mix-blend-mode: difference; /* 随背景反相,始终可见 */
color: #fff; /* 白色源色经 difference 后随底图反相 */
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; } /* 仅保留 Swiper 绑定所需的类,视觉完全由内联类名控制 */
.hs-arrow.swiper-button-disabled { pointer-events: none; } /* 禁用时保持原色,仅不可点击 */
.hs-item {
display: block;
height: auto;
width: max-content;
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 4; */
background: #f0f0f0;
overflow: hidden;
}
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
/* Swiper 容器:高度恒定(内容区固定);宽度由 slide 自适应撑开 */
.hs-swiper {
width: 100%;
height: clamp(500px, 80vh, 900px); /* 固定高度,不随图片尺寸变化(已调高) */
padding: 0 4px;
}
.hs-swiper .swiper-wrapper { align-items: center; } /* 卡片在固定高度的容器内垂直居中 */
/* 加载骨架屏与旋转 Spinner */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
/* 每张 slide:尺寸完全由内部图片决定(slidesPerView:"auto" 取自身宽) */
.hs-swiper .hs-slide {
width: auto;
height: auto;
}
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
.hs-item {
display: block;
height: auto; /* 高度交给图片框,按图片比例自适应 */
width: max-content; /* 宽度收缩到图片自身宽度 */
text-decoration: none;
color: inherit;
}
.hs-img-box {
position: relative;
display: block;
/* 加载前默认占位:统一高度的 3:2 横向占位,避免塌缩;加载后由 JS 按真实比例写入精确 px */
height: clamp(500px, 80vh, 900px);
/* aspect-ratio: 3 / 2; */
background: #f0f0f0;
overflow: hidden;
}
/* 图片:填满图片框;图片框的比例由 JS 按真实宽高比设定,故完整显示、不裁切、不变形 */
.hs-img-box > img {
width: 100%;
height: 100%;
display: block;
position: relative;
z-index: 1;
opacity: 0;
transition: opacity 0.4s ease;
}
.hs-img-box.is-loaded > img { opacity: 1; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* 骨架占位:绝对覆盖 img-box,shimmer 动画;加载完淡出(与图片淡入同步) */
.hs-skel {
position: absolute;
inset: 0;
z-index: 0;
background-image: linear-gradient(100deg, #f0f0f0 30%, #e6e6e6 50%, #f0f0f0 70%);
background-size: 200% 100%;
animation: hs-shimmer 1.3s ease-in-out infinite;
transition: opacity 0.35s ease;
}
.hs-img-box.is-loaded .hs-skel { opacity: 0; }
/* 悬停品牌遮罩 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
/* 加载中旋转环(细线灰调) */
.hs-spin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.hs-img-box.is-loaded .hs-spin { opacity: 0; }
.hs-spin svg { display: block; }
.hs-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
@keyframes hs-rotate { to { transform: rotate(360deg); } }
@keyframes hs-shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* 悬停浮现品牌标签 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 16px 12px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
opacity: 0;
transition: opacity 0.25s ease;
z-index: 3;
font-family: monospace;
pointer-events: none;
}
.hs-item:hover .hs-caption { opacity: 1; }
.hs-cap-brand {
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hs-cap-meta {
font-size: 11px;
opacity: 0.85;
letter-spacing: 0.12em;
}
@media (max-width: 768px) {
.hs-swiper { height: clamp(340px, 56vh, 540px); }
.hs-img-box { height: clamp(340px, 56vh, 540px); }
.hs-head { flex-direction: column; align-items: flex-start; gap: 12px; }
}
/* 卡片上的系列 / 季节标签 */
.prod-meta { @apply flex gap-2 flex-wrap mt-1; }
.prod-chip {
@apply font-mono text-[10px] uppercase tracking-[.1em] px-[8px] py-[3px] rounded-full border border-[#ddd] text-[#666] bg-white;
}
.empty { @apply text-center text-[13px] text-[#999] py-16 font-mono uppercase tracking-[.12em]; }
/* (图片堆方案已移除,改由横向画廊展示多图) */
</style>
<style is:global>
/* JS 启用时画廊图片先透明,加载完成淡入;失败保留占位骨架(is:global 避免组件作用域误伤 .js 选择器) */
.js .hs-img-box > img { opacity: 0; }
.js .hs-img-box.is-loaded > img { opacity: 1; }
.hs-img-box > img.is-error { opacity: 0 !important; }
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
</style>
<script>
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
import Swiper from "swiper";
import { Navigation, FreeMode, Keyboard } from "swiper/modules";
import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/free-mode";
// 标签页交互:默认 Shows 为 SSR;点击其它标签时按需请求接口渲染(懒加载)
const API_BASE = (import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "");
const tabs = Array.from(document.querySelectorAll<HTMLElement>("#tabbar .tab"));
const panels = Array.from(document.querySelectorAll<HTMLElement>(".panel"));
// 初始化 Swiper 轮播
const el = document.getElementById("hsSwiper");
if (el) {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
function toAbs(u: string): string {
if (!u) return "";
return /^https?:\/\//i.test(u) ? u : API_BASE + u;
}
const swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto",
spaceBetween: 4,
grabCursor: true,
speed: 220,
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 },
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
observer: true,
observeSlideChildren: true,
});
// 面板数据区段(与后端真实列表对齐):各面板取不同区段,互不重复
const PANEL_SLICE: Record<string, [number, number]> = {
backstage: [8, 16],
events: [16, 25],
street: [24, 32],
};
const LABEL: Record<string, string> = { shows: "Show", backstage: "Backstage", events: "Trend", street: "Street" };
const SERIES_LABELS: Record<string, string> = { rtw: "成衣 RTW", menswear: "男装", couture: "高定", resort: "度假", pre_fall: "早秋" };
let panelCache: any[] | null = null;
async function ensurePanelData(): Promise<any[]> {
if (panelCache) return panelCache;
const res = await fetch(`${API_BASE}/api/public/articles?page=1&size=32&with_images=5`);
const json = await res.json();
const raw: any[] = json?.data ?? [];
panelCache = raw.map((r) => ({
id: r.id,
brand: r.brand_name || "Unknown",
title: r.title || "",
season: r.year ? "AW" + String(r.year).slice(-2) : "",
season_code: r.season_code || "",
collection_type: r.collection_type || "",
year: r.year,
img: toAbs(r.cover || ""),
count: r.image_count || 0,
gallery: (r.images || []).map((im: any) => toAbs(im.image || "")).filter(Boolean).slice(0, 5),
}));
return panelCache;
}
function skeletonHTML(n = 8): string {
let s = "";
for (let i = 0; i < n; i++) {
s += `<div class="skel-card"><div class="skel-main"></div><div class="skel-line"></div><div class="skel-line short"></div></div>`;
}
return s;
}
function cardHTML(it: any, i: number, label: string): string {
const g = it.gallery && it.gallery.length ? it.gallery : [it.img];
const thumbs = g.map((src: string, gi: number) =>
`<button class="prod-thumb" type="button" aria-label="查看第 ${gi + 1} 张"><img src="${src}" alt="" loading="lazy" /></button>`
).join("");
const more = it.count > g.length
? `<a href="/article?id=${it.id}" class="prod-more">+${it.count - g.length}</a>`
: "";
const badges =
(i === 0 ? `<span class="prod-badge new">新品</span>` : "") +
(it.count > 3 ? `<span class="prod-badge hot">热门</span>` : "");
return `
<article class="prod-card group" data-article-id="${it.id}">
<div class="prod-main">
<img src="${g[0]}" alt="${it.brand}" class="prod-main-img" loading="lazy" />
<span class="prod-tag tl">${label}</span>
${it.count > 1 ? `<span class="prod-tag tr">${it.count} Pics</span>` : ""}
<a href="/article?id=${it.id}" class="prod-viewall">查看全部 ${it.count} 张 →</a>
</div>
<div class="prod-thumbs">${thumbs}${more}</div>
<div class="prod-info">
<div class="prod-name">${it.brand}</div>
<div class="prod-meta">
<span class="prod-chip">${SERIES_LABELS[it.collection_type] || "成衣 RTW"}</span>
${it.season_code ? `<span class="prod-chip">${it.season_code}</span>` : ""}
</div>
<div class="prod-sub">${it.title}</div>
<div class="prod-tags">${badges}</div>
<div class="prod-foot">
<span class="prod-price">${it.season}</span>
<a href="/article?id=${it.id}" class="prod-cta">查看 →</a>
</div>
</div>
</article>`;
}
// function initCardInteractions(root: ParentNode) {
// root.querySelectorAll<HTMLElement>(".prod-card").forEach((card) => {
// const main = card.querySelector<HTMLImageElement>(".prod-main-img");
// const thumbs = Array.from(card.querySelectorAll<HTMLElement>(".prod-thumb"));
// if (!main || !thumbs.length) return;
// thumbs[0].classList.add("active");
// thumbs.forEach((t) => {
// t.addEventListener("click", (e) => {
// e.preventDefault();
// e.stopPropagation();
// const img = t.querySelector("img");
// const src = img?.getAttribute("src") || "";
// if (!src || main.getAttribute("src") === src) return;
// main.classList.remove("is-loaded");
// main.parentElement?.classList.remove("is-loaded");
// main.style.opacity = "0";
// window.setTimeout(() => {
// main.setAttribute("src", src);
// main.style.opacity = "1";
// }, 180);
// thumbs.forEach((x) => x.classList.remove("active"));
// t.classList.add("active");
// });
// });
// main.addEventListener("load", () => {
// main.classList.add("is-loaded");
// main.parentElement?.classList.add("is-loaded");
// });
// enablePinch(card.querySelector<HTMLElement>(".prod-main"));
// });
// root.querySelectorAll<HTMLImageElement>(".prod-main-img, .prod-thumb img").forEach(watchImg);
// }
// async function loadPanel(name: string) {
// const panel = panels.find((p) => p.getAttribute("data-panel") === name);
// if (!panel || panel.getAttribute("data-loaded") === "true" || panel.getAttribute("data-loading") === "true") return;
// panel.setAttribute("data-loading", "true");
// const grid = panel.querySelector<HTMLElement>(".grid");
// if (grid) grid.innerHTML = skeletonHTML(8);
// try {
// const all = await ensurePanelData();
// const [a, b] = PANEL_SLICE[name] || [0, 8];
// const items = all.slice(a, b);
// if (grid) {
// grid.innerHTML = items.map((it, i) => cardHTML(it, i, LABEL[name] || "Show")).join("");
// initCardInteractions(grid);
// }
// panel.setAttribute("data-loaded", "true");
// } catch {
// if (grid) grid.innerHTML = `<div class="text-sm text-gray-400 p-6">内容加载失败,请重试</div>`;
// }
// panel.setAttribute("data-loading", "false");
// }
tabs.forEach((tab) => {
// Brands 是导航型标签(<a href="/brands">):点击直接跳转品牌档案页,不切换面板
if (tab.tagName.toLowerCase() === "a") return;
tab.addEventListener("click", () => {
const target = tab.getAttribute("data-tab");
if (!target) return;
tabs.forEach((t) => t.setAttribute("data-active", String(t === tab)));
panels.forEach((p) => {
p.setAttribute("data-show", String(p.getAttribute("data-panel") === target));
});
// 暂未上线标签:仅切换面板显示占位内容,不请求数据
if (tab.getAttribute("data-soon") === "true") return;
if (target !== "shows") loadPanel(target);
});
});
// 启用 JS 标记:用于渐进增强的占位符淡入(无 JS 时图片直接显示,不影响可用性)
document.documentElement.classList.add("js");
// 图片占位符:加载完成淡入并停止骨架动画;失败保留占位骨架
function watchImg(img: HTMLImageElement) {
const done = () => {
img.classList.add("is-loaded");
img.parentElement?.classList.add("is-loaded");
};
const fail = () => {
img.classList.add("is-error");
img.removeAttribute("src");
};
if (img.complete) {
if (img.naturalWidth > 0) done();
else fail();
} else {
img.addEventListener("load", done, { once: true });
img.addEventListener("error", fail, { once: true });
}
}
// 初始化 SSR 的 Shows 面板卡片交互(缩略图切换 / 占位淡入 / 双指缩放)
// initCardInteractions(document);
// 移动端双指缩放主图(pinch-to-zoom)+ 单指平移
function enablePinch(box: HTMLElement | null) {
if (!box) return;
const img = box.querySelector<HTMLImageElement>(".prod-main-img");
if (!img) return;
let scale = 1, startDist = 0, startScale = 1, panX = 0, panY = 0, sx = 0, sy = 0;
const dist = (a: Touch, b: Touch) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
box.addEventListener("touchstart", (e: TouchEvent) => {
if (e.touches.length === 2) {
startDist = dist(e.touches[0], e.touches[1]);
startScale = scale;
img.style.transition = "none";
} else if (e.touches.length === 1 && scale > 1) {
sx = e.touches[0].clientX - panX;
sy = e.touches[0].clientY - panY;
}
}, { passive: true });
box.addEventListener("touchmove", (e: TouchEvent) => {
if (e.touches.length === 2 && startDist) {
e.preventDefault();
scale = Math.min(4, Math.max(1, startScale * dist(e.touches[0], e.touches[1]) / startDist));
panX = 0; panY = 0;
} else if (e.touches.length === 1 && scale > 1) {
e.preventDefault();
panX = e.touches[0].clientX - sx;
panY = e.touches[0].clientY - sy;
} else {
return;
}
img.style.transform = `scale(${scale}) translate(${panX}px, ${panY}px)`;
}, { passive: false });
box.addEventListener("touchend", () => {
if (scale <= 1) {
scale = 1; panX = 0; panY = 0;
img.style.transition = "";
img.style.transform = "";
} else {
img.style.transition = "transform .2s ease";
}
});
}
// 横向滚动画廊(Swiper):容器高度固定,按每张图片原始宽高比自适应该卡尺寸
(function () {
const el = document.getElementById("hsSwiper");
if (!el) return;
let swiper: any = null;
let updateScheduled = false;
// 图片尺寸随加载/视口变化,需在下一帧通知 Swiper 重新测量布局(防偏移)
const scheduleUpdate = () => {
if (updateScheduled) return;
updateScheduled = true;
requestAnimationFrame(() => { updateScheduled = false; if (swiper) swiper.update(); });
};
// 图片加载:按真实比例设定尺寸 + 完成淡入;失败保留灰底占位
const imgs = el.querySelectorAll<HTMLImageElement>(".hs-img-box img");
imgs.forEach((img) => {
const box = img.closest(".hs-img-box") as HTMLElement | null;
const done = () => {img.classList.add("is-loaded"); box?.classList.add("is-loaded"); scheduleUpdate(); };
const fail = () => {
if (box) { box.style.width = "360px"; box.style.height = (el.clientHeight || 600) + "px"; }
img.style.display = "none";
box?.classList.add("is-loaded");
scheduleUpdate();
};
if (img.complete) { if (img.naturalWidth > 0) done(); else fail(); }
else { img.addEventListener("load", done, { once: true }); img.addEventListener("error", fail, { once: true }); }
});
// 箭头按钮位于容器外的 header 内,直接传元素以确保正确绑定
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev") || document.querySelector(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next") || document.querySelector(".hs-next");
swiper = new Swiper(el, {
modules: [Navigation, FreeMode, Keyboard],
slidesPerView: "auto", // 每张 slide 取自身宽度(由图片宽高比决定)
spaceBetween: 4, // slide 间距
grabCursor: true, // 可拖拽光标
speed: 220, // 箭头/吸附过渡时长(ms),调小让拖拽松手后更快吸附
freeMode: { enabled: true, sticky: true, momentumBounce: false, momentumRatio: 0.5 }, // 自由拖拽 + 松手粘性吸附(momentumRatio 调小减短滑行)
keyboard: { enabled: true },
navigation: { prevEl: prevBtn, nextEl: nextBtn, disabledClass: "swiper-button-disabled" },
updateOnImagesReady: true, // 图片就绪后自动 update
observer: true,
observeSlideChildren: true,
});
})();
</script>
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", () => {
requestAnimationFrame(() => swiper.update());
});
}
</script>