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

This commit is contained in:
toom1996
2026-08-30 22:49:56 +08:00
parent dd66e5b693
commit 662b12c612
10 changed files with 210 additions and 90 deletions

View File

@ -155,28 +155,10 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
{
streetItems.length > 0 ? (
<section class="ss-section">
<!-- 顶部 banner:第 1 条最热街拍 -->
{
streetItems[0] && (
<a class="ss-banner" href={href(locale, ROUTES.streetSnap(streetItems[0].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={streetItems[0].src} alt={streetItems[0].loc} loading="eager" 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">{streetItems[0].loc}</span>
<span class="ss-cap-event">{streetItems[0].event}</span>
</span>
</div>
</a>
)
}
<!-- 2 列大图卡片网格:其余街拍 -->
<!-- 街拍大图卡片网格 -->
<div class="ss-grid">
{
streetItems.slice(1).map((g) => (
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>
@ -372,12 +354,8 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
.ss-section { @apply mb-9; }
.ss-banner {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit mb-7;
aspect-ratio: 16 / 9;
}
.ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-[14px] md:gap-[18px];
@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;
@ -394,8 +372,7 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
}
.ss-img-box.is-loaded > img { opacity: 1; }
.ss-img-box > img.is-error { opacity: 0 !important; }
.ss-card:hover .ss-img-box > img,
.ss-banner:hover .ss-img-box > img { transform: scale(1.04); }
.ss-card:hover .ss-img-box > img { transform: scale(1.04); }
/* 骨架屏 + spinner(与首页 hs-* 平行) */
.ss-skel {
@ -425,22 +402,15 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
gap: 5px; z-index: 3; pointer-events: none;
}
.ss-cap-loc {
font-family: Georgia, "Times New Roman", serif;
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-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
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);
}
.ss-banner .ss-cap { padding: 44px 28px 26px; }
.ss-banner .ss-cap-loc { font-size: clamp(26px, 4vw, 48px); }
@media (min-width: 768px) {
.ss-banner .ss-cap { padding: 56px 36px 30px; }
}
</style>
<script>

View File

@ -303,6 +303,10 @@ const seasonOptions: { value: string; label: string }[] = [
init() {
this.locale = this.$el.dataset.locale || 'en';
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
const ct = new URLSearchParams(window.location.search).get('collection')
const ALLOWED = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
if (ct && ALLOWED.includes(ct)) this.selectedCt = ct
this.loadPage(1)
},

View File

@ -22,6 +22,29 @@ const sortOptions: { value: string; label: string }[] = [
{ value: "year_desc", label: t('Newest year') },
{ value: "year_asc", label: t('Oldest year') },
]
// 地区筛选:值即后端 street_snap.city 列存储的规范英文名(与 backfill_city 词典保持一致)。
// 标签用 t(city) —— 英文环境回退显示英文(巴黎→Paris),中文环境显示中文(Paris→巴黎)。
const cityOptions: { value: string; label: string }[] = [
{ value: "Paris", label: t('Paris') },
{ value: "New York", label: t('New York') },
{ value: "Milan", label: t('Milan') },
{ value: "London", label: t('London') },
{ value: "Copenhagen", label: t('Copenhagen') },
{ value: "Florence", label: t('Florence') },
{ value: "Berlin", label: t('Berlin') },
{ value: "Tokyo", label: t('Tokyo') },
{ value: "Seoul", label: t('Seoul') },
{ value: "Shanghai", label: t('Shanghai') },
{ value: "Beijing", label: t('Beijing') },
{ value: "Rome", label: t('Rome') },
{ value: "Madrid", label: t('Madrid') },
{ value: "Amsterdam", label: t('Amsterdam') },
{ value: "Antwerp", label: t('Antwerp') },
{ value: "Los Angeles", label: t('Los Angeles') },
{ value: "Moscow", label: t('Moscow') },
{ value: "Mumbai", label: t('Mumbai') },
]
---
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
@ -88,6 +111,23 @@ const sortOptions: { value: string; label: string }[] = [
</div>
</section>
<!-- City / 地区 -->
<section class="snapsg">
<header class="snapsg-h" @click="toggleGroup('city')">
<span>{t('City')}</span>
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.city }">
<svg viewBox="0 0 12 12" width="11" height="11"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</header>
<div class="snapsg-b" x-show="!collapsedGroups.city">
<ul class="snapschecks snapschecks--flat">
{cityOptions.map((o) => (
<li><label><input type="radio" name="snapCity" value={o.value} :checked={'selectedCity === "' + o.value + '"'} :class={`{'is-checked': selectedCity === '${o.value}'}`} @click.prevent={'selectedCity = selectedCity === "' + o.value + '" ? "" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
))}
</ul>
</div>
</section>
<button class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
</div>
</aside>
@ -172,7 +212,7 @@ const sortOptions: { value: string; label: string }[] = [
</div>
</div>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions })}></script>
<script id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
@ -193,11 +233,13 @@ const sortOptions: { value: string; label: string }[] = [
SPIN_SVG,
yearOptions: opts.yearOptions || [],
sortOptions: opts.sortOptions || [],
cityOptions: opts.cityOptions || [],
selectedYear: '' as string,
selectedSort: DEFAULT_SORT as string,
selectedCity: '' as string,
// 侧栏分组折叠状态:默认全部收起(不做持久化,每次进页都是关着的)
collapsedGroups: { year: true, sort: true } as Record<string, boolean>,
collapsedGroups: { year: true, sort: true, city: true } as Record<string, boolean>,
items: [] as (StreetSnapItem & { _imgLoaded?: boolean })[],
loading: false,
@ -207,6 +249,10 @@ const sortOptions: { value: string; label: string }[] = [
init() {
this.locale = this.$el.dataset.locale || 'en'
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
const city = new URLSearchParams(window.location.search).get('city')
const allowed = (this.cityOptions || []).map((c: any) => c.value)
if (city && allowed.includes(city)) this.selectedCity = city
this.loadPage(1)
},
@ -217,6 +263,7 @@ const sortOptions: { value: string; label: string }[] = [
clearAll() {
this.selectedYear = ''
this.selectedSort = DEFAULT_SORT
this.selectedCity = ''
this.loadPage(1)
},
@ -237,6 +284,13 @@ const sortOptions: { value: string; label: string }[] = [
clear: () => { this.selectedSort = DEFAULT_SORT; this.loadPage(1) },
})
}
if (this.selectedCity) {
const hit = this.cityOptions.find((o: any) => o.value === this.selectedCity)
out.push({
label: "CITY · " + (hit ? hit.label : this.selectedCity),
clear: () => { this.selectedCity = ''; this.loadPage(1) },
})
}
return out
},
@ -255,6 +309,7 @@ const sortOptions: { value: string; label: string }[] = [
const json = await getStreetSnaps({
page,
year: this.selectedYear || undefined,
city: this.selectedCity || undefined,
sort: this.selectedSort,
})
if (myToken !== this.reqToken) return

View File

@ -21,24 +21,23 @@ export const dict: Record<string, TranslationEntry> = {
// ── 导航 / Navigation ──
'about': { cn: '关于' },
'全部的 runway': { cn: '全部的 runway' },
'contact': { cn: '联系' },
'contact us': { cn: '联系我们' },
'home': { cn: '首页' },
'latest projects': { cn: '最新项目' },
'login': { cn: '登录' },
'portfolio': { cn: '作品集' },
'runway archive': { cn: '走秀档案' },
// ── Runway 子分类(header 下拉,与列表页 COLLECTION 筛选一致)──
'ready-to-wear': { cn: '女装成衣' },
'menswear': { cn: '男装' },
'couture': { cn: '高级定制' },
'resort': { cn: '度假系列' },
'pre-fall': { cn: '早秋系列' },
'signup': { cn: '注册' },
// ── 控件 / Controls ──
@ -108,8 +107,28 @@ export const dict: Record<string, TranslationEntry> = {
'no street snap id specified': { cn: '未指定街拍 ID' },
'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' }
'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' },
// ── 地区 / Cities(街拍按城市分类,值对齐后端 street_snap.city 列;translate 会小写化 key)──
'city': { cn: '地区' },
'paris': { cn: '巴黎' },
'new york': { cn: '纽约' },
'milan': { cn: '米兰' },
'london': { cn: '伦敦' },
'copenhagen': { cn: '哥本哈根' },
'florence': { cn: '佛罗伦萨' },
'berlin': { cn: '柏林' },
'tokyo': { cn: '东京' },
'seoul': { cn: '首尔' },
'shanghai': { cn: '上海' },
'beijing': { cn: '北京' },
'rome': { cn: '罗马' },
'madrid': { cn: '马德里' },
'amsterdam': { cn: '阿姆斯特丹' },
'antwerp': { cn: '安特卫普' },
'los angeles': { cn: '洛杉矶' },
'moscow': { cn: '莫斯科' },
'mumbai': { cn: '孟买' },
};
/** 把 {name} 形式的占位符替换成参数值。 */

View File

@ -30,16 +30,23 @@ const pathWithoutLocale = locales.includes(pathName.split('/')[1])
// 顶部导航数据模型(替代原 Alpine x-data 的 menu/state)。
// 下拉菜单与语言列表均由此驱动渲染,逻辑用 Astro 原生 <script> 控制开合。
const headers = {
// 走秀下拉菜单项(href 统一指向 /shows)
// 走秀下拉菜单项:方案 A —— 全部 + 4 个真实 collection_type 分类(与列表页 COLLECTION 筛选一致),
// 深链带 ?collection= 参数,由 RunwayLooks 的 init() 读取并自动选中筛选。
runways: [
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks), current: true },
{ label: 'Spring / Summer 2026', href: href(locale, ROUTES.shows) },
{ label: 'Fall / Winter 2025', href: href(locale, ROUTES.shows) },
{ label: 'Resort 2026', href: href(locale, ROUTES.shows) },
{ label: 'Pre-Fall 2025', href: href(locale, ROUTES.shows) },
{ label: 'Couture FW25', href: href(locale, ROUTES.shows) },
{ label: 'Menswear SS26', href: href(locale, ROUTES.shows) },
{ label: 'Menswear FW25', href: href(locale, ROUTES.shows) },
{ label: t('全部的 runway'), href: href(locale, ROUTES.runwayLooks) },
{ label: t('Ready-to-Wear'), href: href(locale, ROUTES.runwayLooks) + '?collection=rtw' },
{ label: t('Menswear'), href: href(locale, ROUTES.runwayLooks) + '?collection=menswear' },
{ label: t('Couture'), href: href(locale, ROUTES.runwayLooks) + '?collection=couture' },
{ label: t('Resort'), href: href(locale, ROUTES.runwayLooks) + '?collection=resort' },
],
// 街拍下拉菜单项:按地区分类(值即后端 street_snap.city 列存储的规范英文名),
// 深链带 ?city= 参数,由 StreetSnaps 的 init() 读取并自动选中筛选。
// 仅保留 4 项(全部 + 三座核心时尚之都),完整城市列表在街拍页侧栏 City 筛选里。
streets: [
{ label: t('All Street Snaps'), href: href(locale, ROUTES.streetSnaps) },
{ label: t('Paris'), href: href(locale, ROUTES.streetSnaps) + '?city=Paris' },
{ label: t('New York'), href: href(locale, ROUTES.streetSnaps) + '?city=New York' },
{ label: t('Milan'), href: href(locale, ROUTES.streetSnaps) + '?city=Milan' },
],
// 语言列表(code + 本地名称)
languages: locales.map((l) => ({ code: l, label: LOCALE_NAMES[l].nativeName })),
@ -69,7 +76,7 @@ const headers = {
<!-- 全部的 runway:菜单项用 Astro 渲染(headers.runways.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.shows)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</a>
<a href={href(locale, ROUTES.runwayLooks)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('全部的 runway')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 runway 列表" aria-haspopup="true" :aria-expanded="open">
<svg class="w-2.5 h-2.5 transition-transform duration-200" :class="open ? 'rotate-180' : ''" viewBox="0 0 10 6" fill="none"><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
@ -84,10 +91,24 @@ const headers = {
}
</div>
</div>
<a href={href(locale, ROUTES.latestProjects)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Latest Projects')}</a>
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<a href={href(locale, ROUTES.about)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('About')}</a>
<a href={href(locale, ROUTES.contact)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Contact Us')}</a>
<!-- 街拍:菜单项用 Astro 渲染(headers.streets.map),开合逻辑用 Alpine.js -->
<div class="relative" x-data="{ open: false }" @mouseenter="open = true" @mouseleave="open = false">
<div class="flex items-center gap-1">
<a href={href(locale, ROUTES.streetSnaps)} class="relative pb-[2px] cursor-pointer hover:opacity-60 transition-opacity duration-200">{t('Street Style')}</a>
<button type="button" @click="open = !open" @click.outside="open = false" @keydown.escape.window="open = false" class="pb-[2px] hover:opacity-60 transition-opacity duration-200 flex items-center" aria-label="展开 street style 列表" aria-haspopup="true" :aria-expanded="open">
<svg viewBox="0 0 12 12" width="10" height="10"><path d="M2.5 4.5l3.5 3 3.5-3" stroke="currentColor" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div x-show="open" x-transition.opacity.duration.150ms class="absolute left-0 top-full mt-2 z-50 min-w-[240px] bg-white border border-black/10 shadow-sm py-1">
{
headers.streets.map((item) => (
<a href={item.href} class="block px-4 py-2 text-[12px] uppercase tracking-[0.4px] hover:bg-black/5 transition-colors">
{item.label}
</a>
))
}
</div>
</div>
</div>
<div class="flex items-center gap-3">

View File

@ -417,17 +417,19 @@ export interface RawStreetSnap {
}
// 接口:GET /api/public/street-snaps
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)。
// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)+ 地区(city 精确匹配)。
// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。
export async function getStreetSnaps(q: {
page?: number
year?: number | string
city?: string
sort?: string
} = {}): Promise<StreetSnapPage> {
const p = new URLSearchParams()
p.set("page", String(q.page ?? 1))
if (q.sort) p.set("sort", q.sort)
if (q.year) p.set("year", String(q.year))
if (q.city) p.set("city", q.city)
const j = await request<{ data?: any[]; last_page?: number }>("/public/street-snaps", p)
const mapped: StreetSnapItem[] = (j.data ?? []).map((it: any) => ({
id: String(it.id),

View File

@ -11,9 +11,6 @@ export const ROUTES = {
shows: '/shows',
brands: '/brands',
portfolio: '/portfolio',
latestProjects: '/latest-projects',
about: '/about',
contact: '/contact',
login: '/login',
register: '/register',
runwayLooks: '/runway-looks',