update
This commit is contained in:
44
src/components/CollectionCard.astro
Normal file
44
src/components/CollectionCard.astro
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* 列表卡片(走秀 / 街拍通用)。
|
||||||
|
*
|
||||||
|
* 标记沿用 look-grid.css 的 .showscard-* 系列——与 .snapscard-* 是成对定义、样式完全
|
||||||
|
* 相同的两套类名(见 look-grid.css 顶部注释),故统一用 showscard 不影响街拍视觉。
|
||||||
|
*
|
||||||
|
* 组件本身不接收「数据」prop:它被放在父级 <template x-for="it in items"> 内,由 Astro
|
||||||
|
* 在构建期内联为静态 HTML;卡片内的 Alpine 指令直接引用循环变量 it 与页面 Alpine.data
|
||||||
|
* 提供的方法(itemHref / cardTitle / cardMeta / getCardSlots / toAbsUrl / favState /
|
||||||
|
* toggleFav / SPIN_SVG)。两页只需各自在 extra 里实现 cardTitle / cardMeta / getCardSlots。
|
||||||
|
*
|
||||||
|
* 标题:cardTitle 始终渲染;cardMeta 为空字符串时整行隐藏(街拍用两行,走秀用一行)。
|
||||||
|
*/
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="showscard-wrap relative group">
|
||||||
|
<a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)" target="_blank" rel="noopener">
|
||||||
|
<div class="showscard-left">
|
||||||
|
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||||
|
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
||||||
|
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="showscard-right">
|
||||||
|
<div class="showscard-info">
|
||||||
|
<h3 class="showscard-title" x-text="cardTitle(it)"></h3>
|
||||||
|
<p class="showscard-meta" x-text="cardMeta(it)" x-show="cardMeta(it)"></p>
|
||||||
|
</div>
|
||||||
|
<div class="showscard-strip">
|
||||||
|
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
||||||
|
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
||||||
|
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
|
||||||
|
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
|
||||||
|
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
62
src/components/RefineSidebar.astro
Normal file
62
src/components/RefineSidebar.astro
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* REFINE 侧栏的「通用单选筛选分组」——走秀(collection/season/year)与街拍(year/sort/city)
|
||||||
|
* 只是数组项数不同,结构完全一致。差异通过 groups prop 声明式传入:
|
||||||
|
* - key: 分组键(同时用于 collapsedGroups[key] 折叠态与 radio name)
|
||||||
|
* - title: 分组标题
|
||||||
|
* - options: 选项 [{value, label}]
|
||||||
|
* - selected: 页面 Alpine.data 中该筛选对应的响应式变量名(如 'selectedCt'),
|
||||||
|
* 用于 :checked / :class 与点击赋值。
|
||||||
|
*
|
||||||
|
* 注意:本组件渲染的是静态 HTML,内部 Alpine 指令(toggleGroup / collapsedGroups /
|
||||||
|
* loadPage / clearAll / pills)一律引用父级 x-data 的作用域,不自带状态。
|
||||||
|
* 渲染为「分组列表 + Clear all 按钮」,不含 <aside> 外壳(外壳由各页面提供,
|
||||||
|
* 以便 runway 在前面插入专属的 Designer/品牌分组)。
|
||||||
|
*/
|
||||||
|
interface Option {
|
||||||
|
value: string | number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
interface Group {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
options: Option[]
|
||||||
|
selected: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groups = [] } = Astro.props as { groups: Group[] }
|
||||||
|
---
|
||||||
|
|
||||||
|
{
|
||||||
|
groups.map((g) => (
|
||||||
|
<section class="showsg">
|
||||||
|
<header class="showsg-h" @click={`toggleGroup('${g.key}')`}>
|
||||||
|
<span>{g.title}</span>
|
||||||
|
<span class="showsg-chev" :class={`{'is-collapsed': collapsedGroups.${g.key}}`}>
|
||||||
|
<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="showsg-b" x-show={`!collapsedGroups.${g.key}`}>
|
||||||
|
<ul class="showschecks showschecks--flat">
|
||||||
|
{g.options.map((o) => (
|
||||||
|
<li>
|
||||||
|
<label login-required>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={g.key}
|
||||||
|
value={String(o.value)}
|
||||||
|
:checked={`${g.selected} === '${String(o.value)}'`}
|
||||||
|
:class={`{'is-checked': ${g.selected} === '${String(o.value)}'}`}
|
||||||
|
@click.prevent={`${g.selected} = ${g.selected} === '${String(o.value)}' ? '' : '${String(o.value)}'; loadPage(1)`}
|
||||||
|
/>
|
||||||
|
<span>{o.label}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
<button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
||||||
@ -278,9 +278,12 @@ const browseLabel = t("browse the archive")
|
|||||||
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
// 单图收藏:跳所属图集 + ?img=<图片uid>,详情页据此滚动定位到那张图;
|
||||||
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
// 图集收藏与历史仍用自身 uid,但历史优先用回查到的 canonId(规范 id)——
|
||||||
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
// 旧的无前缀历史 id 会把用户带回非规范地址,详情页据此又写出一条重复历史。
|
||||||
|
// 路由按 f.type 选:street / snap_image → /street-snaps,其余(runway / runway_image)→ /runway-looks。
|
||||||
const isImg = f.kind === "image"
|
const isImg = f.kind === "image"
|
||||||
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
const uid = isImg && f.parentUid ? f.parentUid : (f.canonId || f.id)
|
||||||
const base = clientHref(this.locale, ROUTES.item(uid))
|
const isStreet = f.type === "street" || f.type === "snap_image"
|
||||||
|
const route = isStreet ? ROUTES.streetSnap(uid) : ROUTES.runwayLook(uid)
|
||||||
|
const base = clientHref(this.locale, route)
|
||||||
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
return isImg && f.id ? `${base}?img=${encodeURIComponent(f.id)}` : base
|
||||||
},
|
},
|
||||||
removeFav(id: string) {
|
removeFav(id: string) {
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const streetItems: {
|
|||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
{galleryItems.map((g, i) => (
|
{galleryItems.map((g, i) => (
|
||||||
<div class="swiper-slide hs-slide">
|
<div class="swiper-slide hs-slide">
|
||||||
<a class="hs-item" href={href(locale, ROUTES.item(g.articleId))} data-idx={i}>
|
<a class="hs-item" href={href(locale, ROUTES.runwayLook(g.articleId))} data-idx={i}>
|
||||||
<div class="hs-img-box" x-data="imgLoader()" :class="{ 'is-loaded': loaded || error }">
|
<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>
|
<div class="hs-skel" aria-hidden="true" x-show="!loaded && !error"></div>
|
||||||
|
|
||||||
|
|||||||
@ -6,18 +6,22 @@ import { getSsrArticle, getSsrStreetSnap } from "@/lib/ssr"
|
|||||||
import { getI18n } from "@/lib/i18n"
|
import { getI18n } from "@/lib/i18n"
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
// 按需 SSR:走秀(runway)与街拍(street snap)详情统一收口到 /item/[id]。
|
// 按需 SSR:走秀(runway)与街拍(street snap)详情共用本视图,由页面按类型化路由透传 type。
|
||||||
// 对外 id 带类型前缀(r=runway / s=snap,见后端 hashid.EncodeTyped),据此直接查对应表,无歧义、无需回落。
|
// 对外 id 已是「类型进密码、无前缀」编码(见后端 hashid.EncodeWithType);新类型化路由直接显式传 type。
|
||||||
|
// 旧 /item/[id] 链接无 type 时回落到按 id 首字符嗅探(s=street),后端 Detail handler 对带 r=/s= 前缀的
|
||||||
|
// 旧编码串有向后兼容回退(DecodeTyped),故历史外链仍能正确分流。
|
||||||
//
|
//
|
||||||
// 本组件是 en/cn 两页的共享实现(语言由 Astro.currentLocale 运行时决定)。
|
// 本组件是 en/cn 两页的共享实现(语言由 Astro.currentLocale 运行时决定)。
|
||||||
// 注意:`export const prerender = false` 必须留在**页面**文件里(Astro 只读页面模块的该导出),
|
// 注意:`export const prerender = false` 必须留在**页面**文件里(Astro 只读页面模块的该导出),
|
||||||
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
// 故 id 由页面从 Astro.params 取出后作为 prop 透传进来。
|
||||||
interface Props {
|
interface Props {
|
||||||
id?: string
|
id?: string
|
||||||
|
type?: "runway" | "street"
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = Astro.props
|
const { id, type } = Astro.props
|
||||||
const isSnap = !!id && id[0] === "s"
|
// 优先用页面显式传入的 type(类型化路由);旧 /item/[id] 链接无 type 时按 id 前缀嗅探回退。
|
||||||
|
const isSnap = type ? type === "street" : !!id && id[0] === "s"
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
---
|
---
|
||||||
import BrandModal from "@/components/BrandModal.astro"
|
import BrandModal from "@/components/BrandModal.astro"
|
||||||
|
import CollectionCard from "@/components/CollectionCard.astro"
|
||||||
|
import RefineSidebar from "@/components/RefineSidebar.astro"
|
||||||
import { getSsgHotBrands } from "@/lib/ssg"
|
import { getSsgHotBrands } from "@/lib/ssg"
|
||||||
import { getI18n } from '@/lib/i18n'
|
import { getI18n } from '@/lib/i18n'
|
||||||
|
import { getArticles, getBrands, type ArticleItem, type BrandEntry } from "@/lib/api"
|
||||||
|
import { makeCardSlots } from "@/lib/ui"
|
||||||
|
import { createCollectionPage } from "@/lib/collection-page"
|
||||||
import "@/styles/look-grid.css"
|
import "@/styles/look-grid.css"
|
||||||
|
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
|
|
||||||
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
|
// 热门品牌:走 SSG 专属接口 /api/internal/ssg/brands/popular,后端固定返回热度前 30 个、按热度顺序排列。
|
||||||
@ -60,7 +64,12 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
{ value: "holiday", label: "Holiday" },
|
{ value: "holiday", label: "Holiday" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 通用筛选分组(collection/season/year),品牌(Designer)分组走秀专属、留在页面内。
|
||||||
|
const runwayGroups = [
|
||||||
|
{ key: "collection", title: "COLLECTION", options: collectionOptions, selected: "selectedCt" },
|
||||||
|
{ key: "season", title: "SEASON", options: seasonOptions, selected: "selectedSeason" },
|
||||||
|
{ key: "year", title: "YEAR", options: yearOptions, selected: "selectedYear" },
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
|
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
|
||||||
@ -90,7 +99,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="showsaside-body">
|
<div class="showsaside-body">
|
||||||
<!-- Designer 多选 -->
|
<!-- Designer 多选(走秀专属:热门品牌单选 + 全品牌弹窗) -->
|
||||||
<section class="showsg">
|
<section class="showsg">
|
||||||
<header class="showsg-h" @click="toggleGroup('designers')">
|
<header class="showsg-h" @click="toggleGroup('designers')">
|
||||||
<span>{t('brands')}</span>
|
<span>{t('brands')}</span>
|
||||||
@ -115,58 +124,8 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Collection type -->
|
<!-- 通用分组(collection/season/year) -->
|
||||||
<section class="showsg">
|
<RefineSidebar groups={runwayGroups} />
|
||||||
<header class="showsg-h" @click="toggleGroup('collection')">
|
|
||||||
<span>COLLECTION</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.collection }">
|
|
||||||
<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="showsg-b" x-show="!collapsedGroups.collection">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
{collectionOptions.map((o) => (
|
|
||||||
<li><label login-required><input type="radio" name="collection" value={o.value} :checked={'selectedCt === "' + o.value + '"'} :class={`{'is-checked': selectedCt === '${o.value}'}`} @click.prevent={'selectedCt = selectedCt === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Season -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('season')">
|
|
||||||
<span>SEASON</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.season }">
|
|
||||||
<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="showsg-b" x-show="!collapsedGroups.season">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
{seasonOptions.map((o) => (
|
|
||||||
<li><label login-required><input type="radio" name="season" value={o.value} :checked={'selectedSeason === "' + o.value + '"'} :class={`{'is-checked': selectedSeason === '${o.value}'}`} @click.prevent={'selectedSeason = selectedSeason === "' + o.value + '" ? "" : "' + o.value + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Year -->
|
|
||||||
<section class="showsg">
|
|
||||||
<header class="showsg-h" @click="toggleGroup('year')">
|
|
||||||
<span>YEAR</span>
|
|
||||||
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
|
||||||
<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="showsg-b" x-show="!collapsedGroups.year">
|
|
||||||
<ul class="showschecks showschecks--flat">
|
|
||||||
{yearOptions.map((o) => (
|
|
||||||
<li><label login-required><input type="radio" name="year" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'}`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; refreshFilter()'} /> <span>{o.label}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<button login-required class="showsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -206,33 +165,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
|
|
||||||
<!-- Card List -->
|
<!-- Card List -->
|
||||||
<template x-for="it in items" :key="it.id">
|
<template x-for="it in items" :key="it.id">
|
||||||
<div class="showscard-wrap relative group">
|
<CollectionCard clien />
|
||||||
<a class="showscard" :href="itemHref(it)" :aria-label="cardTitle(it)" target="_blank" rel="noopener">
|
|
||||||
<div class="showscard-left">
|
|
||||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
|
||||||
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-right">
|
|
||||||
<div class="showscard-info">
|
|
||||||
<h3 class="showscard-title" x-text="cardTitle(it)"></h3>
|
|
||||||
</div>
|
|
||||||
<div class="showscard-strip">
|
|
||||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
|
||||||
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
|
|
||||||
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
|
|
||||||
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -258,31 +191,48 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
<script is:inline id="showshotbrands-data" type="application/json" set:html={JSON.stringify(hotBrands)}></script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getArticles, getBrands, type ArticleItem, type BrandEntry } from "@/lib/api"
|
import { getArticles, getBrands, type ArticleItem, type BrandEntry } from "@/lib/api"
|
||||||
import { toAbs } from "@/lib/request"
|
import { makeCardSlots } from "@/lib/ui"
|
||||||
import { getUser } from "@/lib/auth"
|
import { createCollectionPage } from "@/lib/collection-page"
|
||||||
import { clientHref, ROUTES } from "@/lib/routes"
|
|
||||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/ui"
|
const ALLOWED_CT = ['rtw', 'menswear', 'womenswear', 'couture', 'resort', 'pre_fall', 'bridal', 'swimwear']
|
||||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("showsPage", () => {
|
Alpine.data("showsPage", createCollectionPage({
|
||||||
const hotNode = document.getElementById("showshotbrands-data")
|
favType: "runway",
|
||||||
const hotBrands = hotNode?.textContent ? JSON.parse(hotNode.textContent) : []
|
skeletonCount: 24,
|
||||||
|
scrollSelector: ".showsmain",
|
||||||
return {
|
fetch: (self, page) =>
|
||||||
locale: '',
|
getArticles({
|
||||||
hotBrands: hotBrands as { id: string; name: string }[],
|
page,
|
||||||
SPIN_SVG,
|
sort: "newest",
|
||||||
|
brandId: self.selectedBrand || undefined,
|
||||||
|
collection: self.selectedCt || undefined,
|
||||||
|
season: self.selectedSeason || undefined,
|
||||||
|
year: self.selectedYear || undefined,
|
||||||
|
}),
|
||||||
|
onInit: (self) => {
|
||||||
|
// 支持从 header 下拉深链 ?collection=xxx 直接进入对应分类(与侧栏 COLLECTION 筛选一致)
|
||||||
|
const ct = new URLSearchParams(window.location.search).get("collection")
|
||||||
|
if (ct && ALLOWED_CT.includes(ct)) self.selectedCt = ct
|
||||||
|
// 把服务端在构建/SSR 期取到的热门品牌(含 SSG 不可达时的 FAKE_HOT_BRANDS 兜底)从嵌入 JSON 读进客户端状态,
|
||||||
|
// 供侧栏品牌下拉与弹窗 HOT 标签渲染。不读则 this.hotBrands 恒为 [],筛选框无品牌。
|
||||||
|
// 注意:createCollectionPage 以 opts.onInit.call(this, this) 把组件实例作为首个实参传入,故 self 即组件。
|
||||||
|
const el = document.getElementById("showshotbrands-data")
|
||||||
|
if (el && el.textContent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(el.textContent)
|
||||||
|
if (Array.isArray(parsed) && parsed.length) self.hotBrands = parsed
|
||||||
|
} catch {
|
||||||
|
/* 解析失败保持空,由弹窗搜索/字母索引从运行时接口补全 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
hotBrands: [] as { id: string; name: string }[],
|
||||||
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
// 侧栏分组折叠状态:默认全部收起(不做 localStorage 持久化,每次进页都是关着的)。
|
||||||
collapsedGroups: {
|
collapsedGroups: { designers: true, collection: true, season: true, year: true },
|
||||||
designers: true,
|
|
||||||
collection: true,
|
|
||||||
season: true,
|
|
||||||
year: true,
|
|
||||||
},
|
|
||||||
selectedBrand: null as (string | null),
|
selectedBrand: null as (string | null),
|
||||||
selectedCt: "" as string,
|
selectedCt: "" as string,
|
||||||
selectedSeason: "" as string,
|
selectedSeason: "" as string,
|
||||||
@ -296,74 +246,10 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
modalLoading: false,
|
modalLoading: false,
|
||||||
modalSelected: null as (string | null),
|
modalSelected: null as (string | null),
|
||||||
|
|
||||||
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
|
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
|
||||||
loading: false,
|
|
||||||
page: 1,
|
|
||||||
lastPage: 1,
|
|
||||||
reqToken: 0,
|
|
||||||
|
|
||||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
|
||||||
favMap: {} as Record<string, boolean>,
|
|
||||||
|
|
||||||
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
|
|
||||||
// 首页(含筛选后的首页)公开:未登录也直接加载首屏,不弹登录框
|
|
||||||
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
|
|
||||||
this.loadPage(1)
|
|
||||||
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
|
|
||||||
},
|
|
||||||
|
|
||||||
// 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动
|
|
||||||
get skeletonCount() {
|
|
||||||
return 24
|
|
||||||
},
|
|
||||||
|
|
||||||
// 已筛选选项 Pills 视图数据
|
|
||||||
get pills() {
|
|
||||||
const list: { label: string; clear: () => void }[] = []
|
|
||||||
if (this.selectedBrand) {
|
|
||||||
const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
|
|
||||||
const name = findName(this.selectedBrand)
|
|
||||||
list.push({
|
|
||||||
label: "DESIGNER · " + name,
|
|
||||||
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedCt) {
|
|
||||||
list.push({
|
|
||||||
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
|
|
||||||
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedSeason) {
|
|
||||||
list.push({
|
|
||||||
label: "SEASON · " + this.selectedSeason.toUpperCase(),
|
|
||||||
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this.selectedYear) {
|
|
||||||
list.push({
|
|
||||||
label: "YEAR · " + this.selectedYear,
|
|
||||||
clear: () => { this.selectedYear = ""; this.refreshFilter(); }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
},
|
|
||||||
|
|
||||||
// 点击抽屉开关:仅切换内存状态,刷新后回到默认(全部收起)
|
|
||||||
toggleGroup(group: "designers" | "collection" | "season" | "year") {
|
|
||||||
this.collapsedGroups[group] = !this.collapsedGroups[group]
|
|
||||||
},
|
|
||||||
|
|
||||||
refreshFilter() {
|
refreshFilter() {
|
||||||
// 首页公开:筛选切换后重新加载第 1 页,未登录也可查看
|
|
||||||
this.loadPage(1)
|
this.loadPage(1)
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleBrand(id: string) {
|
toggleBrand(id: string) {
|
||||||
this.selectedBrand = this.selectedBrand === id ? null : id
|
this.selectedBrand = this.selectedBrand === id ? null : id
|
||||||
this.refreshFilter()
|
this.refreshFilter()
|
||||||
@ -445,69 +331,48 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
this.refreshFilter()
|
this.refreshFilter()
|
||||||
},
|
},
|
||||||
|
|
||||||
// API 加载(分页:每次整页替换,不追加)
|
// 已筛选选项 Pills 视图数据
|
||||||
async loadPage(page: number) {
|
get pills() {
|
||||||
const myToken = ++this.reqToken
|
const list: { label: string; clear: () => void }[] = []
|
||||||
this.loading = true
|
if (this.selectedBrand) {
|
||||||
this.items = []
|
const findName = (id: any) => this.hotBrands.find((b) => b.id === id)?.name ?? this.modalResults.find((b) => b.id === id)?.name ?? `#${id}`
|
||||||
|
const name = findName(this.selectedBrand)
|
||||||
try {
|
list.push({
|
||||||
const json = await getArticles({
|
label: "DESIGNER · " + name,
|
||||||
page,
|
clear: () => { this.selectedBrand = null; this.refreshFilter(); }
|
||||||
sort: "newest",
|
|
||||||
brandId: this.selectedBrand || undefined,
|
|
||||||
collection: this.selectedCt || undefined,
|
|
||||||
season: this.selectedSeason || undefined,
|
|
||||||
year: this.selectedYear || undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
|
|
||||||
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
|
|
||||||
this.lastPage = json.last_page
|
|
||||||
this.page = page
|
|
||||||
this.items = rows
|
|
||||||
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
|
|
||||||
} catch {
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
this.items = []
|
|
||||||
} finally {
|
|
||||||
if (myToken === this.reqToken) this.loading = false
|
|
||||||
}
|
}
|
||||||
|
if (this.selectedCt) {
|
||||||
|
list.push({
|
||||||
|
label: "COLLECTION · " + this.selectedCt.toUpperCase(),
|
||||||
|
clear: () => { this.selectedCt = ""; this.refreshFilter(); }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.selectedSeason) {
|
||||||
|
list.push({
|
||||||
|
label: "SEASON · " + this.selectedSeason.toUpperCase(),
|
||||||
|
clear: () => { this.selectedSeason = ""; this.refreshFilter(); }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (this.selectedYear) {
|
||||||
|
list.push({
|
||||||
|
label: "YEAR · " + this.selectedYear,
|
||||||
|
clear: () => { this.selectedYear = ""; this.refreshFilter(); }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 分页导航 =====
|
|
||||||
// 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。
|
|
||||||
get pageList() {
|
|
||||||
return buildPageList(this.lastPage, this.page)
|
|
||||||
},
|
|
||||||
|
|
||||||
goPage(p: number) {
|
|
||||||
if (this.loading) return
|
|
||||||
if (p < 1 || p > this.lastPage || p === this.page) return
|
|
||||||
this.loadPage(p)
|
|
||||||
const main = this.$el.querySelector(".showsmain")
|
|
||||||
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
||||||
else window.scrollTo({ top: 0, behavior: "smooth" })
|
|
||||||
},
|
|
||||||
|
|
||||||
prevPage() { this.goPage(this.page - 1) },
|
|
||||||
nextPage() { this.goPage(this.page + 1) },
|
|
||||||
|
|
||||||
// 辅助辅助工具函数
|
|
||||||
toAbsUrl(url: string) { return toAbs(url) },
|
|
||||||
|
|
||||||
// 渲染文章url
|
|
||||||
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
|
|
||||||
|
|
||||||
// 卡片标题:后端 title 通常已含品牌(如 "Etro Spring 2024"),若再拼 brand_name 会重复
|
// 卡片标题:后端 title 通常已含品牌(如 "Etro Spring 2024"),若再拼 brand_name 会重复
|
||||||
// (Etro / Etro Spring 2024)。title 已以品牌开头则只显示 title,否则 "Brand / Title"。
|
// (Etro / Etro Spring 2024)。title 已以品牌开头则只显示 title,否则 "Brand / Title"。
|
||||||
cardTitle(it: any) {
|
cardTitle(it: any) {
|
||||||
const t = String(it?.title || "")
|
const ti = String(it?.title || "")
|
||||||
const b = String(it?.brand_name || "")
|
const b = String(it?.brand_name || "")
|
||||||
if (b && t.toLowerCase().startsWith(b.toLowerCase())) return t
|
if (b && ti.toLowerCase().startsWith(b.toLowerCase())) return ti
|
||||||
return b ? `${b} / ${t}` : t
|
return b ? `${b} / ${ti}` : ti
|
||||||
},
|
},
|
||||||
|
// 走秀单行标题,无副标题
|
||||||
|
cardMeta(it: any) { return "" },
|
||||||
|
|
||||||
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
// 渲染 thumb 图片列表(复用共享工具):固定 5 个槽位 + 末位叠加「共 N 张」遮罩。
|
||||||
//
|
//
|
||||||
@ -521,39 +386,7 @@ const seasonOptions: { value: string; label: string }[] = [
|
|||||||
moreLabel: () => (total > 30 ? "30+" : `${total}+`),
|
moreLabel: () => (total > 30 ? "30+" : `${total}+`),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
},
|
||||||
// ===== 收藏(图片收藏入口)=====
|
}))
|
||||||
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
|
|
||||||
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
|
|
||||||
async markFavs() {
|
|
||||||
const ids = this.items.map((i: any) => String(i.id))
|
|
||||||
if (!ids.length) return
|
|
||||||
// 本地即时填充(同设备秒显)
|
|
||||||
const m: Record<string, boolean> = { ...this.favMap }
|
|
||||||
for (const f of getFavorites()) {
|
|
||||||
const id = String(f.id)
|
|
||||||
if (!(id in m)) m[id] = true
|
|
||||||
}
|
|
||||||
this.favMap = m
|
|
||||||
if (!getUser()) return
|
|
||||||
try {
|
|
||||||
const set = await checkFavorited(ids)
|
|
||||||
const m2: Record<string, boolean> = { ...this.favMap }
|
|
||||||
for (const id of ids) m2[id] = set.has(id)
|
|
||||||
this.favMap = m2
|
|
||||||
} catch {
|
|
||||||
/* 失败维持本地态 */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
favState(id: string) { return !!this.favMap[String(id)] },
|
|
||||||
toggleFav(it: any) {
|
|
||||||
const id = String(it.id)
|
|
||||||
const now = !this.favState(id)
|
|
||||||
toggleFavorite({ id, type: 'runway', title: it.title || '', cover: it.cover || '', brand: it.brand_name || '' })
|
|
||||||
this.favMap = { ...this.favMap, [id]: now }
|
|
||||||
return now
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -1,5 +1,10 @@
|
|||||||
---
|
---
|
||||||
import { getI18n } from '@/lib/i18n'
|
import { getI18n } from '@/lib/i18n'
|
||||||
|
import CollectionCard from "@/components/CollectionCard.astro"
|
||||||
|
import RefineSidebar from "@/components/RefineSidebar.astro"
|
||||||
|
import { getStreetSnaps, type StreetSnapItem } from "@/lib/api"
|
||||||
|
import { makeCardSlots } from "@/lib/ui"
|
||||||
|
import { createCollectionPage } from "@/lib/collection-page"
|
||||||
import "@/styles/look-grid.css"
|
import "@/styles/look-grid.css"
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t } = getI18n(Astro)
|
||||||
@ -42,6 +47,13 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
{ value: "Moscow", label: t('Moscow') },
|
{ value: "Moscow", label: t('Moscow') },
|
||||||
{ value: "Mumbai", label: t('Mumbai') },
|
{ value: "Mumbai", label: t('Mumbai') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 通用筛选分组(year/sort/city)
|
||||||
|
const streetGroups = [
|
||||||
|
{ key: "year", title: t('Year'), options: yearOptions, selected: "selectedYear" },
|
||||||
|
{ key: "sort", title: t('Sort'), options: sortOptions, selected: "selectedSort" },
|
||||||
|
{ key: "city", title: t('City'), options: cityOptions, selected: "selectedCity" },
|
||||||
|
]
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
|
<div class="snapsroot w-full" x-data="streetSnapsPage()" data-locale={locale}>
|
||||||
@ -69,60 +81,8 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<div class="snapsaside-head">
|
<div class="snapsaside-head">
|
||||||
<h2 class="snapsaside-h">REFINE</h2>
|
<h2 class="snapsaside-h">REFINE</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="snapsaside-body">
|
<div class="snapsaside-body">
|
||||||
<!-- Year -->
|
<RefineSidebar groups={streetGroups} />
|
||||||
<section class="snapsg">
|
|
||||||
<header class="snapsg-h" @click="toggleGroup('year')">
|
|
||||||
<span>{t('Year')}</span>
|
|
||||||
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.year }">
|
|
||||||
<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.year">
|
|
||||||
<ul class="snapschecks snapschecks--flat">
|
|
||||||
{yearOptions.map((o) => (
|
|
||||||
<li><label login-required><input type="radio" name="snapYear" value={String(o.value)} :checked={'selectedYear === "' + String(o.value) + '"'} :class={`{'is-checked': selectedYear === '${String(o.value)}'`} @click.prevent={'selectedYear = selectedYear === "' + String(o.value) + '" ? "" : "' + String(o.value) + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Sort -->
|
|
||||||
<section class="snapsg">
|
|
||||||
<header class="snapsg-h" @click="toggleGroup('sort')">
|
|
||||||
<span>{t('Sort')}</span>
|
|
||||||
<span class="snapsg-chev" :class="{ 'is-collapsed': collapsedGroups.sort }">
|
|
||||||
<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.sort">
|
|
||||||
<ul class="snapschecks snapschecks--flat">
|
|
||||||
{sortOptions.map((o) => (
|
|
||||||
<li><label login-required><input type="radio" name="snapSort" value={o.value} :checked={'selectedSort === "' + o.value + '"'} :class={`{'is-checked': selectedSort === '${o.value}'}`} @click.prevent={'selectedSort = selectedSort === "' + o.value + '" ? "image_count" : "' + o.value + '"; loadPage(1)'} /> <span>{o.label}</span></label></li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</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 login-required><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 login-required class="snapsclear" type="button" @click="clearAll()" x-show="pills.length > 0" x-cloak>Clear all ×</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@ -133,7 +93,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝尺寸抖动 -->
|
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝尺寸抖动 -->
|
||||||
<template x-if="loading && items.length === 0">
|
<template x-if="loading && items.length === 0">
|
||||||
<div class="snapsgrid" aria-hidden="true">
|
<div class="snapsgrid" aria-hidden="true">
|
||||||
<template x-for="i in 4" :key="'skel-'+i">
|
<template x-for="i in skeletonCount" :key="'skel-'+i">
|
||||||
<div class="snapscard snapscard--skel">
|
<div class="snapscard snapscard--skel">
|
||||||
<div class="snapscard-left">
|
<div class="snapscard-left">
|
||||||
<div class="snapscard-img"></div>
|
<div class="snapscard-img"></div>
|
||||||
@ -165,33 +125,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
<template x-if="!loading && items.length > 0">
|
<template x-if="!loading && items.length > 0">
|
||||||
<div class="snapsgrid">
|
<div class="snapsgrid">
|
||||||
<template x-for="it in items" :key="it.id">
|
<template x-for="it in items" :key="it.id">
|
||||||
<div class="showscard-wrap relative group">
|
<CollectionCard />
|
||||||
<a class="snapscard" :href="itemHref(it)" :aria-label="cardTitle(it)" target="_blank" rel="noopener">
|
|
||||||
<div class="snapscard-left">
|
|
||||||
<div class="snapscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
|
||||||
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
|
||||||
<img :src="toAbsUrl(it.cover)" :alt="cardTitle(it)" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="snapscard-right">
|
|
||||||
<div class="snapscard-info">
|
|
||||||
<h3 class="snapscard-title" x-text="titleParts(it)[0]"></h3>
|
|
||||||
<p class="snapscard-meta" x-text="titleParts(it)[1]"></p>
|
|
||||||
</div>
|
|
||||||
<div class="snapscard-strip">
|
|
||||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
|
||||||
<div class="snapscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
|
||||||
<span class="snapsspin" aria-hidden="true" x-html="SPIN_SVG" x-show="slot.src"></span>
|
|
||||||
<img x-show="slot.src" :src="toAbsUrl(slot.src)" alt="" loading="lazy" @load="slot.loaded = true" @error="slot.loaded = true" :class="{ 'is-loaded': slot.loaded }" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<button login-required type="button" class="fav-toggle" :class="{ 'is-on': favState(it.id) }" @click.prevent="toggleFav(it)" :aria-pressed="favState(it.id)" :title="favState(it.id) ? 'Saved' : 'Save'">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21C12 21 4 14.5 4 8.8C4 6.1 6.1 4 8.7 4C10.3 4 11.5 4.8 12 6C12.5 4.8 13.7 4 15.3 4C17.9 4 20 6.1 20 8.8C20 14.5 12 21 12 21Z"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -212,67 +146,47 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script is:inline id="snaps-year-opts" type="application/json" set:html={JSON.stringify({ yearOptions, sortOptions, cityOptions })}></script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getStreetSnaps, type StreetSnapItem } from "@/lib/api"
|
import { getStreetSnaps, type StreetSnapItem } from "@/lib/api"
|
||||||
import { toAbs } from "@/lib/request"
|
import { makeCardSlots } from "@/lib/ui"
|
||||||
import { getUser } from "@/lib/auth"
|
import { createCollectionPage } from "@/lib/collection-page"
|
||||||
import { clientHref, ROUTES } from "@/lib/routes"
|
|
||||||
import { SPIN_SVG, makeCardSlots, buildPageList } from "@/lib/ui"
|
|
||||||
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
|
||||||
|
|
||||||
const DEFAULT_SORT = "image_count"
|
const DEFAULT_SORT = "image_count"
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("streetSnapsPage", () => {
|
Alpine.data("streetSnapsPage", createCollectionPage({
|
||||||
const optNode = document.getElementById("snaps-year-opts")
|
favType: "street",
|
||||||
const opts = optNode?.textContent ? JSON.parse(optNode.textContent) : { yearOptions: [], sortOptions: [] }
|
skeletonCount: 4,
|
||||||
|
scrollSelector: ".snapsmain",
|
||||||
return {
|
fetch: (self, page) =>
|
||||||
locale: '',
|
getStreetSnaps({
|
||||||
SPIN_SVG,
|
page,
|
||||||
yearOptions: (opts.yearOptions || []) as { value: number | string; label: string }[],
|
year: self.selectedYear || undefined,
|
||||||
sortOptions: (opts.sortOptions || []) as { value: string; label: string }[],
|
city: self.selectedCity || undefined,
|
||||||
cityOptions: (opts.cityOptions || []) as { value: string; label: string }[],
|
sort: self.selectedSort,
|
||||||
selectedYear: '' as string,
|
}),
|
||||||
|
onInit: (self) => {
|
||||||
|
// 支持从 header 下拉深链 ?city=xxx 直接进入对应地区(与侧栏 City 筛选一致)
|
||||||
|
const city = new URLSearchParams(window.location.search).get("city")
|
||||||
|
const allowed = (self.cityOptions || []).map((c: any) => c.value)
|
||||||
|
if (city && allowed.includes(city)) self.selectedCity = city
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
yearOptions: [] as { value: number | string; label: string }[],
|
||||||
|
sortOptions: [] as { value: string; label: string }[],
|
||||||
|
cityOptions: [] as { value: string; label: string }[],
|
||||||
|
selectedYear: "" as string,
|
||||||
selectedSort: DEFAULT_SORT as string,
|
selectedSort: DEFAULT_SORT as string,
|
||||||
selectedCity: '' as string,
|
selectedCity: "" as string,
|
||||||
|
|
||||||
// 侧栏分组折叠状态:默认全部收起(不做持久化,每次进页都是关着的)
|
// 侧栏分组折叠状态:默认全部收起(不做持久化,每次进页都是关着的)
|
||||||
collapsedGroups: { year: true, sort: true, city: true } as Record<string, boolean>,
|
collapsedGroups: { year: true, sort: true, city: true } as Record<string, boolean>,
|
||||||
|
|
||||||
items: [] as (StreetSnapItem & { _imgLoaded?: boolean })[],
|
|
||||||
loading: false,
|
|
||||||
page: 1,
|
|
||||||
lastPage: 1,
|
|
||||||
reqToken: 0,
|
|
||||||
|
|
||||||
// 收藏态:响应式 id 映射(卡片爱心从此读取;按当前页服务端校验,与收藏总量解耦)
|
|
||||||
favMap: {} as Record<string, boolean>,
|
|
||||||
|
|
||||||
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
|
|
||||||
// 首页公开:未登录也直接加载首屏,不弹登录框
|
|
||||||
// 收藏态在 loadPage 内按当前页校正(markFavs),与收藏总量解耦,10万也常数级
|
|
||||||
this.loadPage(1)
|
|
||||||
// 浏览历史:改为「点开文章详情」时记录(见 item 详情页),列表页不再记。
|
|
||||||
},
|
|
||||||
|
|
||||||
// ===== 侧栏筛选 =====
|
|
||||||
toggleGroup(key: string) {
|
|
||||||
this.collapsedGroups[key] = !this.collapsedGroups[key]
|
|
||||||
},
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
this.selectedYear = ''
|
this.selectedYear = ""
|
||||||
this.selectedSort = DEFAULT_SORT
|
this.selectedSort = DEFAULT_SORT
|
||||||
this.selectedCity = ''
|
this.selectedCity = ""
|
||||||
this.loadPage(1)
|
this.loadPage(1)
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -283,7 +197,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
|
const hit = this.yearOptions.find((o: any) => String(o.value) === String(this.selectedYear))
|
||||||
out.push({
|
out.push({
|
||||||
label: "YEAR · " + (hit ? hit.label : this.selectedYear),
|
label: "YEAR · " + (hit ? hit.label : this.selectedYear),
|
||||||
clear: () => { this.selectedYear = ''; this.loadPage(1) },
|
clear: () => { this.selectedYear = ""; this.loadPage(1) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.selectedSort !== DEFAULT_SORT) {
|
if (this.selectedSort !== DEFAULT_SORT) {
|
||||||
@ -297,7 +211,7 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
const hit = this.cityOptions.find((o: any) => o.value === this.selectedCity)
|
const hit = this.cityOptions.find((o: any) => o.value === this.selectedCity)
|
||||||
out.push({
|
out.push({
|
||||||
label: "CITY · " + (hit ? hit.label : this.selectedCity),
|
label: "CITY · " + (hit ? hit.label : this.selectedCity),
|
||||||
clear: () => { this.selectedCity = ''; this.loadPage(1) },
|
clear: () => { this.selectedCity = ""; this.loadPage(1) },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@ -311,91 +225,19 @@ const cityOptions: { value: string; label: string }[] = [
|
|||||||
return makeCardSlots(imgs)
|
return makeCardSlots(imgs)
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadPage(page: number) {
|
|
||||||
const myToken = ++this.reqToken
|
|
||||||
this.loading = true
|
|
||||||
this.items = []
|
|
||||||
try {
|
|
||||||
const json = await getStreetSnaps({
|
|
||||||
page,
|
|
||||||
year: this.selectedYear || undefined,
|
|
||||||
city: this.selectedCity || undefined,
|
|
||||||
sort: this.selectedSort,
|
|
||||||
})
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
const rows = json.data.map((item: StreetSnapItem) => ({ ...item, _imgLoaded: false }))
|
|
||||||
this.lastPage = json.last_page
|
|
||||||
this.page = page
|
|
||||||
this.items = rows
|
|
||||||
this.markFavs() // 当前页可见 id 走服务端校验收藏态(与收藏总量解耦)
|
|
||||||
} catch {
|
|
||||||
if (myToken !== this.reqToken) return
|
|
||||||
this.items = []
|
|
||||||
} finally {
|
|
||||||
if (myToken === this.reqToken) this.loading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
get pageList() {
|
|
||||||
return buildPageList(this.lastPage, this.page)
|
|
||||||
},
|
|
||||||
|
|
||||||
goPage(p: number) {
|
|
||||||
if (this.loading) return
|
|
||||||
if (p < 1 || p > this.lastPage || p === this.page) return
|
|
||||||
this.loadPage(p)
|
|
||||||
const main = this.$el.querySelector(".snapsmain")
|
|
||||||
if (main) main.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
||||||
else window.scrollTo({ top: 0, behavior: "smooth" })
|
|
||||||
},
|
|
||||||
prevPage() { this.goPage(this.page - 1) },
|
|
||||||
nextPage() { this.goPage(this.page + 1) },
|
|
||||||
|
|
||||||
toAbsUrl(url: string) { return toAbs(url) },
|
|
||||||
// 街拍详情与走秀统一收口 /item/[id],前端按编码前缀 r/s 分流(见后端 hashid.EncodeTyped)
|
|
||||||
itemHref(it: { id: string | number }) { return clientHref(this.locale, ROUTES.item(it.id)) },
|
|
||||||
|
|
||||||
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
|
// theimpression 标题含字面 \t:地点 \t 场次,拆成 [location, event]
|
||||||
titleParts(it: StreetSnapItem): string[] {
|
titleParts(it: StreetSnapItem): string[] {
|
||||||
const parts = (it.title || "").split("\t")
|
const parts = (it.title || "").split("\t")
|
||||||
return [parts[0] || it.title || "", parts.slice(1).join(" ").trim()]
|
return [parts[0] || it.title || "", parts.slice(1).join(" ").trim()]
|
||||||
},
|
},
|
||||||
|
// 街拍:标题行取地点,副标题行取场次
|
||||||
cardTitle(it: StreetSnapItem): string {
|
cardTitle(it: StreetSnapItem): string {
|
||||||
return (it.title || "").replace(/\t/g, " · ")
|
return this.titleParts(it)[0]
|
||||||
},
|
},
|
||||||
|
cardMeta(it: StreetSnapItem): string {
|
||||||
// ===== 收藏(图片收藏入口)=====
|
return this.titleParts(it)[1]
|
||||||
// 按当前页可见 id 拉服务端收藏态:本地先即时填充,登录态异步用服务端校正。
|
|
||||||
// 复杂度仅与「一屏多少张」相关,与用户收藏总量解耦,即便 10 万收藏也常数级。
|
|
||||||
async markFavs() {
|
|
||||||
const ids = this.items.map((i: any) => String(i.id))
|
|
||||||
if (!ids.length) return
|
|
||||||
// 本地即时填充(同设备秒显)
|
|
||||||
const m: Record<string, boolean> = { ...this.favMap }
|
|
||||||
for (const f of getFavorites()) {
|
|
||||||
const id = String(f.id)
|
|
||||||
if (!(id in m)) m[id] = true
|
|
||||||
}
|
|
||||||
this.favMap = m
|
|
||||||
if (!getUser()) return
|
|
||||||
try {
|
|
||||||
const set = await checkFavorited(ids)
|
|
||||||
const m2: Record<string, boolean> = { ...this.favMap }
|
|
||||||
for (const id of ids) m2[id] = set.has(id)
|
|
||||||
this.favMap = m2
|
|
||||||
} catch {
|
|
||||||
/* 失败维持本地态 */
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
favState(id: string) { return !!this.favMap[String(id)] },
|
},
|
||||||
toggleFav(it: any) {
|
}))
|
||||||
const id = String(it.id)
|
|
||||||
const now = !this.favState(id)
|
|
||||||
toggleFavorite({ id, type: 'street', title: it.title || '', cover: it.cover || '', brand: (it as any).brand || '' })
|
|
||||||
this.favMap = { ...this.favMap, [id]: now }
|
|
||||||
return now
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
148
src/lib/collection-page.ts
Normal file
148
src/lib/collection-page.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 列表页(走秀 / 街拍 / 未来更多模块)共享的 Alpine 行为逻辑工厂。
|
||||||
|
*
|
||||||
|
* 设计动机:RunwayLooks.astro 与 StreetSnaps.astro 原先各有一份 ~400 行的 Alpine.data,
|
||||||
|
* 其中「加载 / 分页 / 收藏态校正 / 翻页滚动 / 卡片工具」等约 80% 完全重复,只有几处差异:
|
||||||
|
* - fetch 调的接口与入参(selectedXxx)
|
||||||
|
* - 收藏 type('runway' | 'street')
|
||||||
|
* - 卡片标题 / 副标题 / 缩略图来源的格式化(gallery string[] vs images[])
|
||||||
|
* - 深链参数(?collection= vs ?city=)
|
||||||
|
* - 骨架屏数量、翻页滚动目标容器
|
||||||
|
*
|
||||||
|
* 本工厂收口「不变的逻辑」,把「变的差异」通过 opts 注入,页面只保留自己专属的
|
||||||
|
* selected 变量 / pills / clearAll / 品牌弹窗等。新增模块 = 写一个 opts + 薄壳页面。
|
||||||
|
*
|
||||||
|
* 注意:Alpine.data 返回的是普通对象,无继承,故用 {...base, ...opts.extra} 做字段合并;
|
||||||
|
* 所有方法内的 `this` 在运行时都指向 Alpine 组件实例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toAbs } from "@/lib/request"
|
||||||
|
import { getUser } from "@/lib/auth"
|
||||||
|
import { clientHref, ROUTES } from "@/lib/routes"
|
||||||
|
import { buildPageList, SPIN_SVG } from "@/lib/ui"
|
||||||
|
import { toggleFavorite, checkFavorited, getFavorites } from "@/lib/favorites"
|
||||||
|
|
||||||
|
export interface CollectionPageOptions {
|
||||||
|
/** 收藏类型,写入 favorites 表 */
|
||||||
|
favType: "runway" | "street"
|
||||||
|
/** 骨架屏卡片数量(走秀首屏 24、街拍 4) */
|
||||||
|
skeletonCount?: number
|
||||||
|
/** 翻页后滚动回顶的目标容器选择器 */
|
||||||
|
scrollSelector?: string
|
||||||
|
/** 拉取数据:self 即组件实例,可读取 this.selectedXxx 拼接入参 */
|
||||||
|
fetch: (self: any, page: number) => Promise<{ data: any[]; last_page: number }>
|
||||||
|
/** 深链初始化(如 ?collection= / ?city=),在 loadPage(1) 之前执行 */
|
||||||
|
onInit?: (self: any) => void
|
||||||
|
/** 页面专属字段(selected 变量、pills、clearAll、collapsedGroups、品牌弹窗方法、cardTitle/cardMeta/getCardSlots 等) */
|
||||||
|
extra?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCollectionPage(opts: CollectionPageOptions) {
|
||||||
|
return function () {
|
||||||
|
const base: Record<string, any> = {
|
||||||
|
locale: "",
|
||||||
|
SPIN_SVG,
|
||||||
|
|
||||||
|
loading: false,
|
||||||
|
page: 1,
|
||||||
|
lastPage: 1,
|
||||||
|
reqToken: 0,
|
||||||
|
favMap: {} as Record<string, boolean>,
|
||||||
|
items: [] as any[],
|
||||||
|
|
||||||
|
get skeletonCount() {
|
||||||
|
return opts.skeletonCount ?? 24
|
||||||
|
},
|
||||||
|
get pageList() {
|
||||||
|
return buildPageList(this.lastPage, this.page)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 侧栏分组折叠:通用逻辑,collapsedGroups 由各页 extra 提供键集合
|
||||||
|
toggleGroup(key: string) {
|
||||||
|
const c = this.collapsedGroups as Record<string, boolean>
|
||||||
|
c[key] = !c[key]
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadPage(page: number) {
|
||||||
|
const myToken = ++this.reqToken
|
||||||
|
this.loading = true
|
||||||
|
this.items = []
|
||||||
|
try {
|
||||||
|
const json = await opts.fetch(this, page)
|
||||||
|
if (myToken !== this.reqToken) return
|
||||||
|
const rows = json.data.map((item: any) => ({ ...item, _imgLoaded: false }))
|
||||||
|
this.lastPage = json.last_page
|
||||||
|
this.page = page
|
||||||
|
this.items = rows
|
||||||
|
this.markFavs()
|
||||||
|
} catch {
|
||||||
|
if (myToken !== this.reqToken) return
|
||||||
|
this.items = []
|
||||||
|
} finally {
|
||||||
|
if (myToken === this.reqToken) this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goPage(p: number) {
|
||||||
|
if (this.loading) return
|
||||||
|
if (p < 1 || p > this.lastPage || p === this.page) return
|
||||||
|
this.loadPage(p)
|
||||||
|
const main = this.$el.querySelector(opts.scrollSelector || ".showsmain")
|
||||||
|
if (main) (main as HTMLElement).scrollIntoView({ behavior: "smooth", block: "start" })
|
||||||
|
else window.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
},
|
||||||
|
prevPage() { this.goPage(this.page - 1) },
|
||||||
|
nextPage() { this.goPage(this.page + 1) },
|
||||||
|
|
||||||
|
toAbsUrl(url: string) { return toAbs(url) },
|
||||||
|
itemHref(it: { id: string | number }) {
|
||||||
|
// 按当前列表模块的收藏类型选对应类型化路由(与列表页 slug 一致)。
|
||||||
|
const route = opts.favType === "street" ? ROUTES.streetSnap(it.id) : ROUTES.runwayLook(it.id)
|
||||||
|
return clientHref(this.locale, route)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 收藏态:按当前页可见 id 拉服务端校验,与收藏总量解耦(10 万收藏也常数级)
|
||||||
|
async markFavs() {
|
||||||
|
const ids = this.items.map((i: any) => String(i.id))
|
||||||
|
if (!ids.length) return
|
||||||
|
const m: Record<string, boolean> = { ...this.favMap }
|
||||||
|
for (const f of getFavorites()) {
|
||||||
|
const id = String(f.id)
|
||||||
|
if (!(id in m)) m[id] = true
|
||||||
|
}
|
||||||
|
this.favMap = m
|
||||||
|
if (!getUser()) return
|
||||||
|
try {
|
||||||
|
const set = await checkFavorited(ids)
|
||||||
|
const m2: Record<string, boolean> = { ...this.favMap }
|
||||||
|
for (const id of ids) m2[id] = set.has(id)
|
||||||
|
this.favMap = m2
|
||||||
|
} catch {
|
||||||
|
/* 失败维持本地态 */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
favState(id: string) { return !!this.favMap[String(id)] },
|
||||||
|
toggleFav(it: any) {
|
||||||
|
const id = String(it.id)
|
||||||
|
const now = !this.favState(id)
|
||||||
|
toggleFavorite({
|
||||||
|
id,
|
||||||
|
type: opts.favType,
|
||||||
|
title: it.title || "",
|
||||||
|
cover: it.cover || "",
|
||||||
|
brand: it.brand_name || (it as any).brand || "",
|
||||||
|
})
|
||||||
|
this.favMap = { ...this.favMap, [id]: now }
|
||||||
|
return now
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.locale = (this.$el as HTMLElement).dataset.locale || "en"
|
||||||
|
if (opts.onInit) opts.onInit.call(this, this)
|
||||||
|
this.loadPage(1)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...base, ...(opts.extra || {}) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,10 +14,11 @@ export const ROUTES = {
|
|||||||
// 街拍
|
// 街拍
|
||||||
streetSnaps: '/street-snaps',
|
streetSnaps: '/street-snaps',
|
||||||
// 动态路由(工厂函数)。
|
// 动态路由(工厂函数)。
|
||||||
// 走秀与街拍详情**统一收口到 /item/[id]**(src/pages/{locale}/item/[id].astro),
|
// 走秀 / 街拍详情按内容类型拆分到独立路由,与列表页 slug 保持一致;
|
||||||
// 由前端按编码前缀(r=runway / s=snap)分流到对应后端接口。
|
// 前端按类型化路由分流到对应后端强类型接口(/runway-looks/:id、/street-snaps/:id)。
|
||||||
item: (id: string | number) => `/item/${id}`,
|
// 旧 /item/[id] 仅保留为 301 重定向(见 src/pages/{locale}/item/[id].astro),按 id 前缀回退。
|
||||||
streetSnap: (id: string | number) => `/item/${id}`,
|
runwayLook: (id: string | number) => `/runway-looks/${id}`,
|
||||||
|
streetSnap: (id: string | number) => `/street-snaps/${id}`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */
|
/** 服务端:生成带 locale 前缀的完整 URL(依赖 astro:i18n,遵循 astro.config)。 */
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 旧 /item/[id] 保留为 301 重定向,按 id 类型前缀回退到对应类型化路由,避免外链 / SEO 权重失效。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// r=runway → /runway-looks,s=street → /street-snaps,其余(含图像 i/j 经 parentUid 已回退)回落 runway。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import { ROUTES, clientHref } from "@/lib/routes"
|
||||||
---
|
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} />
|
const id = Astro.params.id || ""
|
||||||
|
const isSnap = id[0] === "s"
|
||||||
|
const target = isSnap ? ROUTES.streetSnap(id) : ROUTES.runwayLook(id)
|
||||||
|
return Astro.redirect(clientHref(Astro.currentLocale || "cn", target), 301)
|
||||||
|
---
|
||||||
|
|||||||
10
src/pages/cn/runway-looks/[id].astro
Normal file
10
src/pages/cn/runway-looks/[id].astro
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
// 走秀详情:类型化路由 /runway-looks/[id]。
|
||||||
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
||||||
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
|
import ItemPage from "@/components/views/ItemPage.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<ItemPage id={Astro.params.id} type="runway" />
|
||||||
10
src/pages/cn/street-snaps/[id].astro
Normal file
10
src/pages/cn/street-snaps/[id].astro
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
// 街拍详情:类型化路由 /street-snaps/[id]。
|
||||||
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
||||||
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
|
import ItemPage from "@/components/views/ItemPage.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<ItemPage id={Astro.params.id} type="street" />
|
||||||
@ -1,9 +1,12 @@
|
|||||||
---
|
---
|
||||||
export const prerender = false
|
export const prerender = false
|
||||||
|
|
||||||
// 语言由 Astro.currentLocale 运行时决定,实现见 components/views/ItemPage.astro(en/cn 共用)。
|
// 旧 /item/[id] 保留为 301 重定向,按 id 类型前缀回退到对应类型化路由,避免外链 / SEO 权重失效。
|
||||||
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
// r=runway → /runway-looks,s=street → /street-snaps,其余(含图像 i/j 经 parentUid 已回退)回落 runway。
|
||||||
import ItemPage from "@/components/views/ItemPage.astro"
|
import { ROUTES, clientHref } from "@/lib/routes"
|
||||||
---
|
|
||||||
|
|
||||||
<ItemPage id={Astro.params.id} />
|
const id = Astro.params.id || ""
|
||||||
|
const isSnap = id[0] === "s"
|
||||||
|
const target = isSnap ? ROUTES.streetSnap(id) : ROUTES.runwayLook(id)
|
||||||
|
return Astro.redirect(clientHref(Astro.currentLocale || "en", target), 301)
|
||||||
|
---
|
||||||
|
|||||||
10
src/pages/en/runway-looks/[id].astro
Normal file
10
src/pages/en/runway-looks/[id].astro
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
// 走秀详情:类型化路由 /runway-looks/[id]。
|
||||||
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
||||||
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
|
import ItemPage from "@/components/views/ItemPage.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<ItemPage id={Astro.params.id} type="runway" />
|
||||||
10
src/pages/en/street-snaps/[id].astro
Normal file
10
src/pages/en/street-snaps/[id].astro
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
export const prerender = false
|
||||||
|
|
||||||
|
// 街拍详情:类型化路由 /street-snaps/[id]。
|
||||||
|
// 语言由 Astro.currentLocale 运行时决定,渲染实现见 components/views/ItemPage.astro(en/cn 共用)。
|
||||||
|
// id 必须在此取出后透传:prerender 只认页面模块的导出,Astro.params 留在页面更直观。
|
||||||
|
import ItemPage from "@/components/views/ItemPage.astro"
|
||||||
|
---
|
||||||
|
|
||||||
|
<ItemPage id={Astro.params.id} type="street" />
|
||||||
Reference in New Issue
Block a user