This commit is contained in:
toom1996
2026-09-13 21:39:50 +08:00
parent c85c561a99
commit d6cb616337
24 changed files with 1076 additions and 813 deletions

View File

@ -66,7 +66,7 @@ const fullLoginLabel = t('go to full login')
</div>
<script>
import { getUser, login } from "@/lib/api"
import { getUser, login } from "@/lib/auth"
document.addEventListener("alpine:init", () => {
// 全局登录 store:request(el) 返回是否放行;未登录记录 pending 并开层

View File

@ -1,24 +1,17 @@
---
import ComingSoon from "@/components/ComingSoon.astro"
import { toAbs } from "@/lib/api"
import { toAbs, getSsgIndexRunway, getSsgStreetSnapPopular } from "@/lib/api"
import { getI18n } from "@/i18n/utils"
import { href, ROUTES } from '@/lib/routes'
import { getSsgIndexRunway } from "@/lib/api"
import { getSsgStreetSnapPopular } from "@/lib/api"
import { href, ROUTES } from "@/lib/routes"
// 国际化:一行取当前语言 + 翻译函数
const { locale, t } = getI18n(Astro)
// 首页「热门品牌」区块:构建期走 SSG 内部接口 /api/internal/ssg/home/runway(server-only),
// 首页 runway 数据含多模块,hotBrand 为其中之一;封面/标题取自每个品牌代表走秀,
// 直接烧入静态 HTML,运行期不再请求;后端不可达则静默留空(区块隐藏)。
const popular = (await getSsgIndexRunway()).hotBrand
type GalleryItem = {
src: string
brand: string
articleId: string // 代表走秀 hashid 编码串(后端返回 string)
articleId: string
title: string
}
@ -31,10 +24,14 @@ const galleryItems: GalleryItem[] = popular
title: b.title,
}))
// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/internal/ssg/street-snaps/popular(server-only),
// 返回按图片数前 N 的街拍(含封面),直接烧入静态 HTML;后端不可达则静默留空(区块回退 ComingSoon)。
const streetPopular = (await getSsgStreetSnapPopular(12)).filter((b) => b.cover && b.id)
const streetItems: { src: string; loc: string; event: string; id: string }[] = streetPopular.map((b) => {
const streetItems: {
src: string
loc: string
event: string
id: string
}[] = streetPopular.map((b) => {
const parts = (b.title || "").split("\t")
return {
src: toAbs(b.cover || ""),
@ -44,393 +41,582 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
}
})
---
<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'"
>
Runway
<!-- <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 === 'street' }"
@click="activeTab = 'street'"
>
Street Style
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
Social Media
</li>
</ul>
<!-- 二级分类标签页 -->
<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'"
>
Runway
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'street' }"
@click="activeTab = 'street'"
>
Street Style
</li>
<li
class="tab text-[42px] font-extrabold cursor-pointer whitespace-nowrap"
:class="{ 'is-active': activeTab === 'events' }"
@click="activeTab = 'events'"
>
Social Media
</li>
</ul>
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- Runway -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">
Hot Brand
</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={href(locale, ROUTES.item(g.articleId))} data-idx={i}>
<div class="hs-img-box" x-data="imgLoader()" :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="handleLoad()"
@error="handleError()"
/>
<span class="hs-caption font-sans">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">{g.title}</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"
>
{t('Previous')}
</div>
<div
class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200"
role="button"
tabindex="0"
>
{t('Next')}
</div>
</div>
</div>
</section>
)
}
</div>
<!-- 中间内容展示区 -->
<main class="grow min-h-[400px] mt-10">
<!-- 发布会 面板(默认激活) -->
<div class="panel" x-show="activeTab === 'backstage'" x-transition.opacity.duration.300ms>
<div class="container px-md md:px-xl text-2xl md:text-2xl-1 font-medium mb-4">Hot Brand</div>
<!-- Social Media -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon
module="社媒"
moduleZh="Social"
note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
/>
</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={href(locale, ROUTES.item(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 font-sans">
<span class="hs-cap-brand">{g.brand}</span>
<span class="hs-cap-meta">{g.title}</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">{t('Previous')}</div>
<div class="text-[16px] hs-arrow hs-next cursor-pointer hover:opacity-60 transition-opacity duration-200" role="button" tabindex="0">{t('Next')}</div>
</div>
</div>
</section>
)
}
</div>
<!-- Street Style -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
{
streetItems.length > 0 ? (
<section class="ss-section">
<div class="ss-grid">
{streetItems.map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<!-- 社媒 面板 -->
<div class="panel" x-show="activeTab === 'events'" x-transition.opacity.duration.300ms x-cloak>
<ComingSoon module="社媒" moduleZh="Social" note="社媒板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
</div>
<span class="ss-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="ss-ring" />
</svg>
</span>
<!-- 街拍 面板(构建期 SSG 拉取热门街拍封面;去掉 swiper,改为 HBX 大图网格) -->
<div class="panel" x-show="activeTab === 'street'" x-transition.opacity.duration.300ms x-cloak>
{
streetItems.length > 0 ? (
<section class="ss-section">
<!-- 街拍大图卡片网格 -->
<div class="ss-grid">
{
streetItems.map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-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="ss-ring" /></svg>
</span>
<img src={g.src} alt={g.loc} loading="lazy" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<span class="ss-cap">
<span class="ss-cap-loc">{g.loc}</span>
<span class="ss-cap-event">{g.event}</span>
</span>
</div>
</a>
))
}
<img
src={g.src}
alt={g.loc}
loading="lazy"
decoding="async"
:class="{ 'is-error': error }"
@load="handleLoad()"
@error="handleError()"
/>
<span class="ss-cap">
<span class="ss-cap-loc">{g.loc}</span>
<span class="ss-cap-event">{g.event}</span>
</span>
</div>
</section>
) : (
<ComingSoon module="街拍" moduleZh="Street" note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。" />
)
}
</div>
</main>
</a>
))}
</div>
</section>
) : (
<ComingSoon
module="街拍"
moduleZh="Street"
note="街拍板块正在筹备中,敬请期待。你可以先浏览已上线的走秀档案。"
/>
)
}
</div>
</main>
</div>
<style>
@reference "tailwindcss";
@reference "tailwindcss";
:root { --brand: #cf5a44; }
:root {
--brand: #cf5a44;
}
/* 标签基础与激活样式 */
.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; }
/* 标签 */
.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;
}
[x-cloak] {
display: none !important;
}
/* 防闪烁标签 */
[x-cloak] { display: none !important; }
/* ========================= Hot Brand ========================= */
.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;
}
.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;
}
.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);
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;
}
/* 横向画廊样式 */
.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-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-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);
}
.hs-controls .hs-arrow { pointer-events: auto; }
.hs-arrow { @apply select-none; }
.hs-arrow.swiper-button-disabled { pointer-events: none; }
}
@keyframes hs-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
/* 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;
}
.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; }
/* 加载骨架屏与旋转 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; }
.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; }
}
/* 底部品牌/标题信息——左下角大标题+小副标题,始终可见 */
/* Hot Brand 文案 */
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 48px 24px 22px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
color: white;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 6px;
opacity: 1;
z-index: 3;
pointer-events: none;
}
.hs-cap-brand {
@apply font-sans;
font-size: 21px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
line-height: 1.1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
}
.hs-cap-meta {
@apply font-sans;
font-size: 13px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.04em;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.hs-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 48px 24px 22px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78) 0%, rgba(0, 0, 0, 0.28) 55%, transparent 100%);
color: white;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 6px;
opacity: 1;
z-index: 3;
pointer-events: none;
padding: 60px 28px 26px;
}
.hs-cap-brand {
@apply font-sans;
font-size: 21px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
line-height: 1.1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
font-size: 26px;
}
.hs-cap-meta {
@apply font-sans;
font-size: 13px;
font-weight: 500;
opacity: 0.9;
letter-spacing: 0.04em;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
max-width: 90%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
}
}
@media (max-width: 768px) {
.hs-swiper,
.hs-img-box {
height: clamp(340px, 56vh, 540px);
}
}
@media (min-width: 768px) {
.hs-caption { padding: 60px 28px 26px; }
.hs-cap-brand { font-size: 26px; }
.hs-cap-meta { font-size: 15px; }
}
/* ========================= Street Style ========================= */
.ss-section {
@apply mb-9;
}
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
}
.ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
aspect-ratio: 3 / 4;
}
.ss-img-box {
@apply absolute inset-0 w-full h-full overflow-hidden;
background: #f0f0f0;
}
.ss-img-box > img {
@apply absolute inset-0 w-full h-full object-cover;
opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease;
}
.ss-img-box.is-loaded > img {
opacity: 1;
}
.ss-img-box > img.is-error {
opacity: 0 !important;
}
.ss-card:hover .ss-img-box > img {
transform: scale(1.04);
}
@media (max-width: 768px) {
.hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); }
}
/* Street Style 骨架 */
.ss-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;
}
.ss-img-box.is-loaded .ss-skel {
opacity: 0;
}
.ss-spin {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
color: #bdbdbd;
pointer-events: none;
transition: opacity 0.3s ease;
}
.ss-img-box.is-loaded .ss-spin {
opacity: 0;
}
.ss-spin svg {
display: block;
}
.ss-ring {
transform-origin: 12px 12px;
animation: hs-rotate 0.8s linear infinite;
}
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
.ss-section { @apply mb-9; }
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
}
.ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
aspect-ratio: 3 / 4;
}
.ss-img-box {
@apply absolute inset-0 w-full h-full overflow-hidden;
background: #f0f0f0;
}
.ss-img-box > img {
@apply absolute inset-0 w-full h-full object-cover;
opacity: 0;
transition: opacity 0.45s ease, transform 0.7s ease;
}
.ss-img-box.is-loaded > img { opacity: 1; }
.ss-img-box > img.is-error { opacity: 0 !important; }
.ss-card:hover .ss-img-box > img { transform: scale(1.04); }
/* 骨架屏 + spinner(与首页 hs-* 平行) */
.ss-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;
}
.ss-img-box.is-loaded .ss-skel { opacity: 0; }
.ss-spin {
position: absolute; inset: 0; z-index: 2;
display: flex; align-items: center; justify-content: center;
color: #bdbdbd; pointer-events: none;
transition: opacity 0.3s ease;
}
.ss-img-box.is-loaded .ss-spin { opacity: 0; }
.ss-spin svg { display: block; }
.ss-ring { transform-origin: 12px 12px; animation: hs-rotate 0.8s linear infinite; }
/* 底部叠层白字(loc / event) */
.ss-cap {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 28px 20px 18px;
background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.34) 55%, transparent 100%);
color: white; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-end;
gap: 5px; z-index: 3; pointer-events: none;
}
.ss-cap-loc {
font-size: clamp(18px, 2vw, 26px);
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.ss-cap-event {
font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4;
opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
/* Street Style 文案 */
.ss-cap {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 28px 20px 18px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.34) 55%, transparent 100%);
color: white;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 5px;
z-index: 3;
pointer-events: none;
}
.ss-cap-loc {
font-size: clamp(18px, 2vw, 26px);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
}
.ss-cap-event {
font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.16em;
line-height: 1.4;
opacity: 0.85;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
</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"
// 初始化 Swiper 轮播(首页可能有多个:Hot Brand / Street Style)
const swipers: Swiper[] = []
document.querySelectorAll<HTMLElement>(".hs-swiper").forEach((el) => {
const prevBtn = el.parentElement?.querySelector<HTMLElement>(".hs-prev");
const nextBtn = el.parentElement?.querySelector<HTMLElement>(".hs-next");
/* ---------------- Swiper 实例管理 ---------------- */
let swipers = []
let updateScheduled = false
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,
observeParents: true,
observeSlideChildren: true,
resizeObserver: true,
});
swipers.push(swiper);
});
// RAF 合并:同一帧内多张图片加载完成 / 多个 resize,只执行一次 swiper.update()
function scheduleSwiperUpdate() {
if (updateScheduled) return
updateScheduled = true
requestAnimationFrame(() => {
updateScheduled = false
for (const swiper of swipers) {
if (!swiper.destroyed) swiper.update()
}
})
}
// Street Style 等面板默认 x-show 隐藏,Swiper 在 display:none 下初始化会把 slide 算成 0 宽,
// 切到对应 tab 后必须 update() 一次重算尺寸,否则图片虽在但宽度 0 不可见。
const updateAllSwipers = () =>
requestAnimationFrame(() => swipers.forEach((s) => s.update()));
document.querySelectorAll<HTMLElement>(".tab").forEach((tab) => {
tab.addEventListener("click", () => setTimeout(updateAllSwipers, 60));
});
window.addEventListener("resize", updateAllSwipers);
// 接收 Alpine 发布的图片就绪事件更新布局防闪烁
window.addEventListener("img-ready", updateAllSwipers);
</script>
// 销毁已有实例:防止重复初始化 / View Transitions 重新进入时旧实例泄漏
function destroySwipers() {
for (const swiper of swipers) {
if (!swiper.destroyed) swiper.destroy(true, true)
}
swipers = []
}
function initSwipers() {
destroySwipers()
document.querySelectorAll(".hs-swiper").forEach((el) => {
const prevBtn = el.parentElement?.querySelector(".hs-prev")
const nextBtn = el.parentElement?.querySelector(".hs-next")
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,
observeParents: true,
observeSlideChildren: true,
resizeObserver: true,
})
swipers.push(swiper)
})
scheduleSwiperUpdate()
}
// 统一入口:延后一帧,避免首次进入时 DOM / Alpine / Swiper 初始化时序互相抢
function setupSwipers() {
requestAnimationFrame(() => initSwipers())
}
// 首次普通加载:等 DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupSwipers, { once: true })
} else {
setupSwipers()
}
// Astro View Transitions 客户端导航进入:重新初始化
document.addEventListener("astro:page-load", setupSwipers)
// Tab 切换后重算尺寸(事件委托,兼容 View Transitions 后的新 DOM)
document.addEventListener("click", (e) => {
if (e.target.closest(".tab")) {
setTimeout(scheduleSwiperUpdate, 60)
}
})
// 浏览器尺寸变化
window.addEventListener("resize", scheduleSwiperUpdate)
// 图片加载完成(每张图的 Alpine 组件 dispatch "img-ready")后更新 Swiper
window.addEventListener("img-ready", scheduleSwiperUpdate)
/* ---------------- 共享图片加载状态组件 ---------------- */
// Hot Brand 与 Street Style 复用同一份逻辑:
// 1) init() 检查 complete + naturalWidth,缓存已完成的图片立即置位,不一直显示骨架;
// 2) 保留 @load / @error,处理 Alpine 初始化之后才完成的图片。
document.addEventListener("alpine:init", () => {
Alpine.data("imgLoader", () => ({
loaded: false,
error: false,
init() {
const img = this.$el.querySelector("img")
if (!img) return
if (img.complete) {
this.loaded = img.naturalWidth > 0
this.error = img.naturalWidth === 0
if (this.loaded || this.error) this.$dispatch("img-ready")
}
},
handleLoad() {
this.error = false
this.loaded = true
this.$dispatch("img-ready")
},
handleError() {
this.loaded = false
this.error = true
this.$dispatch("img-ready")
},
}))
})
</script>

View File

@ -45,7 +45,7 @@ for (const d of (article?.details ?? []) as { look_index?: number; images?: unkn
}
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
@ -214,7 +214,7 @@ const homeHref = href(locale, ROUTES.home)
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
@ -585,8 +585,9 @@ const homeHref = href(locale, ROUTES.home)
</div>
<script>
import { getUser, getArticleDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
import { getArticleDetailAuthed, toAbs } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("articleView", () => ({
@ -609,6 +610,8 @@ const homeHref = href(locale, ROUTES.home)
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录态图集级(gallery,r=/s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
galleryFav: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; thumb: string; raw: string; name: string; lookIndex?: number }[],
@ -647,39 +650,53 @@ const homeHref = href(locale, ROUTES.home)
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
// 后端详情接口对已登录(带 Bearer)返回完整图片集,故此处能拿到全量。
async loadFull() {
if (!this.preview) return
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
// gallery 级(r=)收藏态广播给页面右下角 fav-fab;image 级(i=)收藏态直接重建 favIds。
async loadAuthedState() {
if (!getUser()) return
try {
const full = await getArticleDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""),
raw: im.image || "",
name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
}))
this.extraImages = mapped
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
this.galleryFav = !!full.favorited
window.dispatchEvent(
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
)
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
const favIds: string[] = []
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
// 超预览上限才追加第 6 张起图片(前 5 张已由 SSR 渲染,仅补收藏态)
if (this.preview) {
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
thumb: toAbs(im.thumb || im.image || ""),
raw: im.image || "",
name: im.name || "",
lookIndex: Number(im.look_index ?? 0),
}))
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#article-gallery [data-image-index]"
).length
;(full.images || []).slice(5).forEach(mark)
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
}
// 前 5 张收藏态
;(full.images || []).slice(0, 5).forEach(mark)
this.favIds = favIds
} catch {
/* 失败维持截断态,门禁仍在 */
/* 失败维持本地/截断态,门禁仍在 */
}
},
@ -768,15 +785,12 @@ const homeHref = href(locale, ROUTES.home)
.filter(Boolean)
},
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
// 本地先即时填充,避免爱心闪烁
this.favIds = ids.filter((id) => isFavorited(id))
// 登录态用服务端校正(换设备后与服务端一致)
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {

View File

@ -72,7 +72,7 @@ const seasonOptions: { value: string; label: string }[] = [
<span slot="current">{t('Runway looks')}</span>
</Breadcrumb>
<h1 class="showsh1">全部的 runway</h1>
<h1 class="showsh1">{t('all runway looks')}</h1>
</div>
</header>
@ -265,7 +265,8 @@ const seasonOptions: { value: string; label: string }[] = [
<script>
import { getArticles, getBrands, toAbs, getUser, type ArticleItem, type BrandEntry } from "@/lib/api"
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"

View File

@ -31,7 +31,7 @@ const images = (snap?.images ?? [])
.filter((im) => im.url)
// 未登录预览上限:详情页(图集)未登录最多展示前 PREVIEW_LIMIT 张图片;其余由登录门禁提示,
// 已登录(客户端 loadFull 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
// 已登录(客户端 loadAuthedState 拉全量)或图片本就不超上限时全部展示。前 PREVIEW_LIMIT 张服务端已渲染(SEO/无 JS 友好)。
const PREVIEW_LIMIT = 5
const previewImages = images.slice(0, PREVIEW_LIMIT)
@ -141,7 +141,7 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
))
}
{/* 登录后追加的额外图片:extraImages 由客户端 loadFull() 在已登录时填充,未登录为空不渲染 */}
{/* 登录后追加的额外图片:extraImages 由客户端 loadAuthedState() 在已登录时填充,未登录为空不渲染 */}
<template x-for="(im, i) in extraImages" :key="im.url">
<figure
:data-image-index="5 + i"
@ -351,8 +351,9 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
</div>
<script>
import { getUser, getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
import { isFavorited, toggleImageFavorite, checkFavorited } from "@/lib/favorites"
import { getStreetSnapDetailAuthed, toAbs } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { isFavorited, toggleImageFavorite } from "@/lib/favorites"
document.addEventListener("alpine:init", () => {
Alpine.data("streetSnapView", () => ({
@ -374,6 +375,8 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
isAuthed: false,
// 未登录门禁:后端截断图片集时为 true(详情页最多看 PREVIEW_LIMIT 张)
preview: false,
// 已登录态图集级(gallery,s=)收藏态:由 loadAuthedState 从详情接口取回,广播给 fav-fab
galleryFav: false,
// 已登录后客户端补拉的全量图片(前 PREVIEW_LIMIT 张已由 SSR 服务端渲染)
extraImages: [] as { id: string; url: string; raw: string; name: string }[],
@ -392,36 +395,49 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
this.initImageFavs()
this.maybeLocateImageFromUrl()
// 已登录且后端未登录截断(preview):客户端拉全量图片集并追加渲染
if (getUser() && this.preview) this.loadFull()
// 登录后(如从门禁跳登录回跳)自动补全全量
window.addEventListener("auth:login", () => { this.isAuthed = true; if (this.preview) this.loadFull() })
// 已登录:用一次详情请求拿回完整图片集 + 收藏态(合并原三处 /me/favorites/checks 调用)
if (getUser()) this.loadAuthedState()
// 登录后(如从门禁跳登录回跳)自动补全全量 + 收藏态
window.addEventListener("auth:login", () => { this.isAuthed = true; if (getUser()) this.loadAuthedState() })
},
// 已登录用户:拉取完整图片集,把第 PREVIEW_LIMIT+1 张起的图片追加进 extraImages 渲染。
async loadFull() {
if (!this.preview) return
// 已登录:用一次详情请求拿回「完整图片集 + 收藏态」,合并原三处 /me/favorites/checks 调用。
// gallery 级(s=)收藏态广播给页面右下角 fav-fab;image 级(j=)收藏态直接重建 favIds。
async loadAuthedState() {
if (!getUser()) return
try {
const full = await getStreetSnapDetailAuthed(this.galleryUid)
if (!full || !Array.isArray(full.images)) return
const mapped = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
this.extraImages = mapped
// 后端详情接口对所有用户统一返回 high 高清样式(付费墙已取消),首屏图即为高清,
// 无需客户端再按 VIP 替换。仅把第 6 张起的图片追加进 extraImages 渲染。
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
this.initImageFavs()
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
// gallery 级收藏态 → 广播给 fav-fab(去掉单篇 /me/favorites/checks 查询)
this.galleryFav = !!full.favorited
window.dispatchEvent(
new CustomEvent("fav:set", { detail: { id: this.galleryUid, fav: this.galleryFav } }),
)
// image 级收藏态:以服务端 favorited 为准重建 favIds(覆盖 initImageFavs 的本地初值)
const favIds: string[] = []
const mark = (im: any) => { if (im && im.id && im.favorited) favIds.push(im.id) }
if (this.preview) {
this.extraImages = (full.images || []).slice(5).map((im: any) => ({
id: im.id || "",
url: toAbs(im.image || ""),
raw: im.image || "",
name: im.name || "",
}))
await this.$nextTick()
this.imageCount = document.querySelectorAll(
"#snap-gallery [data-image-index]"
).length
;(full.images || []).slice(5).forEach(mark)
this.initThumbLazyLoading()
this.maybeLocateImageFromUrl()
}
;(full.images || []).slice(0, 5).forEach(mark)
this.favIds = favIds
} catch {
/* 失败维持截断态,门禁仍在 */
/* 失败维持本地/截断态,门禁仍在 */
}
},
@ -479,13 +495,12 @@ const displayTitle = (snap?.title || "").replace(/\t/g, " · ")
.filter(Boolean)
},
// 本地即时填充收藏态(未登录态,或登录态加载服务端数据前的占位,避免爱心闪烁)。
// 登录态的服务端校正已合并进 loadAuthedState(),不再单独请求 /me/favorites/checks。
initImageFavs() {
const ids = this.allImageFavIds()
if (!ids.length) return
this.favIds = ids.filter((id) => isFavorited(id))
checkFavorited(ids).then((hit) => {
this.favIds = ids.filter((id) => hit.has(id))
})
},
isFav(id: string) {

View File

@ -221,7 +221,8 @@ const cityOptions: { value: string; label: string }[] = [
<script>
import { getStreetSnaps, toAbs, getUser, type StreetSnapItem } from "@/lib/api"
import { getStreetSnaps, toAbs, type StreetSnapItem } from "@/lib/api"
import { getUser } from "@/lib/auth"
import { clientHref, ROUTES } from "@/lib/routes"
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/looks-grid"
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"