diff --git a/src/components/Index.astro b/src/components/Index.astro index 456a887..175230a 100644 --- a/src/components/Index.astro +++ b/src/components/Index.astro @@ -5,6 +5,7 @@ import { toAbs } from "@/lib/api" import { getI18n } from "@/i18n/utils" import { href, ROUTES } from '@/lib/routes' import { getSsgIndexRunway } from "@/lib/api" +import { getSsgStreetSnapPopular } from "@/lib/api" // 国际化:一行取当前语言 + 翻译函数 const { locale, t } = getI18n(Astro) @@ -29,6 +30,19 @@ const galleryItems: GalleryItem[] = popular articleId: b.article_id, title: b.title, })) + +// 首页「Street Style」热门街拍:构建期走 SSG 内部接口 /api/v1/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 parts = (b.title || "").split("\t") + return { + src: toAbs(b.cover || ""), + loc: parts[0] || b.title || "", + event: parts.slice(1).join(" ").trim(), + id: b.id, + } +}) ---
@@ -44,8 +58,8 @@ const galleryItems: GalleryItem[] = popular
  • Street Style
  • @@ -136,9 +150,54 @@ const galleryItems: GalleryItem[] = popular
    - +
    - + { + streetItems.length > 0 ? ( +
    + + { + streetItems[0] && ( + +
    + + + {streetItems[0].loc} + + {streetItems[0].loc} + {streetItems[0].event} + +
    +
    + ) + } + +
    + { + streetItems.slice(1).map((g) => ( + +
    + + + {g.loc} + + {g.loc} + {g.event} + +
    +
    + )) + } +
    +
    + ) : ( + + ) + }
    @@ -310,6 +369,78 @@ const galleryItems: GalleryItem[] = popular @media (max-width: 768px) { .hs-swiper, .hs-img-box { height: clamp(340px, 56vh, 540px); } } + + /* ===== 首页 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]; + } + .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, + .ss-banner: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-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; } + } \ No newline at end of file diff --git a/src/components/Item.astro b/src/components/Item.astro index e5244e0..ea0d238 100644 --- a/src/components/Item.astro +++ b/src/components/Item.astro @@ -1,5 +1,5 @@ --- -import type { RawArticle } from "@/lib/data" +import type { RawArticle } from "@/lib/api" import Breadcrumb from "@/components/Breadcrumb.astro" import { getI18n } from "@/i18n/utils" import { href, ROUTES } from '@/lib/routes' diff --git a/src/components/RunwayLooks.astro b/src/components/RunwayLooks.astro index 828b8ef..6897780 100644 --- a/src/components/RunwayLooks.astro +++ b/src/components/RunwayLooks.astro @@ -10,24 +10,24 @@ const { locale, t, getRelativeLocaleUrl } = getI18n(Astro) // 侧栏 filter 展示前 10 个(模板内 slice(0,10)),品牌弹窗 HOT 标签展示全部 30 个。 // 接口不可用时回落假数据先看效果。 const FAKE_HOT_BRANDS = [ - { id: "hot-chanel", name: "Chanel", article_count: 342 }, - { id: "hot-dior", name: "Dior", article_count: 318 }, - { id: "hot-gucci", name: "Gucci", article_count: 287 }, - { id: "hot-prada", name: "Prada", article_count: 254 }, - { id: "hot-louisvuitton", name: "Louis Vuitton", article_count: 241 }, - { id: "hot-saintlaurent", name: "Saint Laurent", article_count: 219 }, - { id: "hot-balenciaga", name: "Balenciaga", article_count: 198 }, - { id: "hot-valentino", name: "Valentino", article_count: 176 }, - { id: "hot-givenchy", name: "Givenchy", article_count: 162 }, - { id: "hot-burberry", name: "Burberry", article_count: 153 }, - { id: "hot-fendi", name: "Fendi", article_count: 141 }, - { id: "hot-bottega", name: "Bottega Veneta", article_count: 128 }, + { id: "hot-chanel", name: "Chanel" }, + { id: "hot-dior", name: "Dior" }, + { id: "hot-gucci", name: "Gucci" }, + { id: "hot-prada", name: "Prada" }, + { id: "hot-louisvuitton", name: "Louis Vuitton" }, + { id: "hot-saintlaurent", name: "Saint Laurent" }, + { id: "hot-balenciaga", name: "Balenciaga" }, + { id: "hot-valentino", name: "Valentino" }, + { id: "hot-givenchy", name: "Givenchy" }, + { id: "hot-burberry", name: "Burberry" }, + { id: "hot-fendi", name: "Fendi" }, + { id: "hot-bottega", name: "Bottega Veneta" }, ] -let hotBrands: { id: string; name: string; article_count: number }[] = [] +let hotBrands: { id: string; name: string }[] = [] try { hotBrands = (await getSsgHotBrands()) .filter((b) => b.id && b.name) - .map((b) => ({ id: String(b.id), name: b.name, article_count: b.article_count })) + .map((b) => ({ id: String(b.id), name: b.name })) } catch {} if (!hotBrands.length) hotBrands = FAKE_HOT_BRANDS @@ -108,9 +108,8 @@ const seasonOptions: { value: string; label: string }[] = [ @@ -130,7 +129,7 @@ const seasonOptions: { value: string; label: string }[] = [
    @@ -147,7 +146,7 @@ const seasonOptions: { value: string; label: string }[] = [
    @@ -164,7 +163,7 @@ const seasonOptions: { value: string; label: string }[] = [
    @@ -235,9 +234,16 @@ const seasonOptions: { value: string; label: string }[] = [ -
    - -
    + @@ -258,6 +264,7 @@ const seasonOptions: { value: string; label: string }[] = [ placeholder="搜索品牌名…" autocomplete="off" /> +

    最多展示 30 条结果,可用搜索或字母筛选缩小范围

    @@ -273,14 +280,13 @@ const seasonOptions: { value: string; label: string }[] = [
  • @@ -368,7 +374,7 @@ const seasonOptions: { value: string; label: string }[] = [ .showschecks { - @apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin] overscroll-contain; + @apply list-none mt-[10px] mb-0 p-0 max-h-[280px] overflow-y-auto [scrollbar-width:thin]; } .showschecks li { @apply flex items-center gap-2 py-[5px] text-[11.5px] uppercase tracking-[0.04em] text-black cursor-pointer select-none hover:bg-[#f6f6f6]; @@ -376,12 +382,14 @@ const seasonOptions: { value: string; label: string }[] = [ .showschecks li.letter-head { @apply p-[8px_0_3px] text-[10px] tracking-[0.22em] text-[#8a8a8a] cursor-default pointer-events-none border-t border-dotted border-[#e6e6e6] first:border-t-0 first:pt-1; } - .showschecks input[type="checkbox"] { - @apply appearance-none w-3 h-3 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-none inline-grid place-content-center checked:bg-black; + .showschecks input[type="radio"] { + @apply appearance-none w-3.5 h-3.5 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full; } - .showschecks input[type="checkbox"]:checked::after { - content: ""; - @apply w-[6px] h-[6px] bg-white; + /* 选中态由 Alpine 状态驱动(.is-checked),不依赖原生 :checked 伪类, + 因为 @click.prevent 会阻止原生选中,伪类不可靠;实心黑圆一眼可辨 */ + .showschecks input[type="radio"].is-checked { + @apply bg-white border-black; + background-image: radial-gradient(circle, #000 36%, transparent 40%); } .showschecks .ct { @apply text-[#999] text-[10px] ml-auto; @@ -493,11 +501,26 @@ const seasonOptions: { value: string; label: string }[] = [ animation: hbxShim 1.6s ease infinite; } - .showsloadmore-wrap { - @apply flex justify-center mt-[38px]; + .showspager { + @apply flex flex-wrap items-center justify-center gap-2 mt-[38px]; } - .showsloadmore { - @apply border-[1.5px] border-black bg-white text-black p-[13px_36px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-200 hover:bg-black hover:text-white disabled:opacity-40 disabled:cursor-default; + .pg { + @apply min-w-[40px] h-[40px] px-3 border-[1.5px] border-black bg-white text-black text-[11px] uppercase tracking-[0.12em] font-bold cursor-pointer rounded-none transition-colors duration-200; + } + .pg.is-current { + @apply bg-black text-white cursor-default; + } + .pg:disabled { + @apply opacity-30 cursor-default; + } + .pg.enabled:hover { + @apply bg-black text-white; + } + .pg-ellipsis { + @apply px-1 text-[#999] select-none text-[11px]; + } + .pg-wrap { + @apply inline-flex items-center; } .showsempty { @@ -546,6 +569,9 @@ const seasonOptions: { value: string; label: string }[] = [ .showsmodal-input { @apply w-full border border-black/15 px-3 py-2 text-[13px] outline-none bg-[#fafafa] focus:bg-white focus:border-black; } + .showsmodal-tip { + @apply mt-2 text-[11px] leading-snug text-black/45 font-mono; + } .showsmodal-az { @apply flex flex-wrap gap-1 px-4 pb-3 shrink-0; } @@ -573,6 +599,13 @@ const seasonOptions: { value: string; label: string }[] = [ .showsmodal-row { @apply flex items-center gap-2 w-full cursor-pointer px-3 py-2 text-[13px] transition-colors hover:bg-black/5; } + .showsmodal-row input[type="radio"] { + @apply appearance-none w-4 h-4 border-[1.5px] border-black bg-white cursor-pointer shrink-0 rounded-full; + } + .showsmodal-row input[type="radio"].is-checked { + @apply bg-white border-black; + background-image: radial-gradient(circle, #000 36%, transparent 40%); + } .showsmodal-name { @apply flex-1 truncate; } @@ -616,10 +649,10 @@ const seasonOptions: { value: string; label: string }[] = [ season: localStorage.getItem("showsg-season") === "1", year: localStorage.getItem("showsg-year") === "1", }, - selectedBrands: [] as number[], - selectedCt: [] as string[], - selectedSeason: [] as string[], - selectedYear: [] as string[], + selectedBrand: null as (string | null), + selectedCt: "" as string, + selectedSeason: "" as string, + selectedYear: "" as string, // 品牌筛选弹窗状态 modalOpen: false, @@ -627,91 +660,74 @@ const seasonOptions: { value: string; label: string }[] = [ modalLetter: "", modalResults: [] as BrandEntry[], modalLoading: false, - modalSelected: [] as number[], - get modalMaxReached() { - return this.modalSelected.length >= 1 - }, + modalSelected: null as (string | null), items: [] as (ArticleItem & { _imgLoaded?: boolean })[], loading: false, - loadingMore: false, page: 1, - size: 24, - total: 0, - isEnd: false, + lastPage: 1, reqToken: 0, init() { this.locale = this.$el.dataset.locale || 'en'; - this.loadPage(1, false) + this.loadPage(1) }, - // 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动 + // 骨架屏数量与首屏每页条数一致(后端固定 ArticleFixedSize=24),消除加载抖动 get skeletonCount() { - return this.size + return 24 }, // 已筛选选项 Pills 视图数据 get pills() { const list: { label: string; clear: () => void }[] = [] - if (this.selectedBrands.length) { + 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 labels = this.selectedBrands - .map((id) => findName(id)) - .slice(0, 4) - const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : "" + const name = findName(this.selectedBrand) list.push({ - label: "DESIGNER · " + labels.join(", ") + more, - clear: () => { this.selectedBrands = []; this.refreshFilter(); } + label: "DESIGNER · " + name, + clear: () => { this.selectedBrand = null; this.refreshFilter(); } }) } - if (this.selectedCt.length) { + if (this.selectedCt) { list.push({ - label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(), - clear: () => { this.selectedCt = []; this.refreshFilter(); } + label: "COLLECTION · " + this.selectedCt.toUpperCase(), + clear: () => { this.selectedCt = ""; this.refreshFilter(); } }) } - if (this.selectedSeason.length) { + if (this.selectedSeason) { list.push({ - label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(), - clear: () => { this.selectedSeason = []; this.refreshFilter(); } + label: "SEASON · " + this.selectedSeason.toUpperCase(), + clear: () => { this.selectedSeason = ""; this.refreshFilter(); } }) } - if (this.selectedYear.length) { + if (this.selectedYear) { list.push({ - label: "YEAR · " + [...this.selectedYear].sort().join("+"), - clear: () => { this.selectedYear = []; this.refreshFilter(); } + label: "YEAR · " + this.selectedYear, + clear: () => { this.selectedYear = ""; this.refreshFilter(); } }) } return list }, - get loadBtnText() { - if (this.loadingMore) return "Loading…" - if (this.isEnd) return "— End of Archive —" - return "Load More ↓" - }, - // 事件处理方法 toggleGroup(group: "designers" | "collection" | "season" | "year") { this.collapsedGroups[group] = !this.collapsedGroups[group] localStorage.setItem(`showsg-${group}`, this.collapsedGroups[group] ? "1" : "") }, - toggleBrand(id: number) { - const idx = this.selectedBrands.indexOf(id) - if (idx >= 0) this.selectedBrands.splice(idx, 1) - else this.selectedBrands.push(id) - this.refreshFilter() + refreshFilter() { + this.loadPage(1) }, - refreshFilter() { - this.loadPage(1, false) + toggleBrand(id: string) { + this.selectedBrand = this.selectedBrand === id ? null : id + this.refreshFilter() }, // ===== 品牌筛选弹窗方法 ===== async openBrandModal() { - this.modalSelected = [...this.selectedBrands] + this.modalSelected = this.selectedBrand this.modalKeyword = "" this.modalLetter = "HOT" this.modalOpen = true @@ -730,7 +746,7 @@ const seasonOptions: { value: string; label: string }[] = [ this.modalLetter = "HOT" this.modalLoading = true try { - this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name, article_count: b.article_count || 0 })) + this.modalResults = this.hotBrands.map((b) => ({ id: b.id, name: b.name })) } finally { this.modalLoading = false } @@ -742,7 +758,7 @@ const seasonOptions: { value: string; label: string }[] = [ this.modalLoading = true this.modalResults = [] try { - this.modalResults = await getBrands({ keyword: kw, size: 60 }) + this.modalResults = await getBrands({ keyword: kw }) } catch { this.modalResults = [] } finally { @@ -766,23 +782,15 @@ const seasonOptions: { value: string; label: string }[] = [ this.modalResults = [] try { const letter = l === "#" ? "OTHER" : l - this.modalResults = await getBrands({ letter, size: 200 }) + this.modalResults = await getBrands({ letter }) } catch { this.modalResults = [] } finally { this.modalLoading = false } }, - toggleModalBrand(id: number) { - const idx = this.modalSelected.indexOf(id) - if (idx >= 0) this.modalSelected.splice(idx, 1) - else { - if (this.modalSelected.length >= 5) return - this.modalSelected.push(id) - } - }, confirmBrandModal() { - this.selectedBrands = [...this.modalSelected] + this.selectedBrand = this.modalSelected this.modalOpen = false document.documentElement.style.overflow = "" document.documentElement.style.paddingRight = "" @@ -790,54 +798,73 @@ const seasonOptions: { value: string; label: string }[] = [ }, clearAll() { - this.selectedBrands = [] - this.selectedCt = [] - this.selectedSeason = [] - this.selectedYear = [] + this.selectedBrand = null + this.selectedCt = "" + this.selectedSeason = "" + this.selectedYear = "" this.refreshFilter() }, - // API 加载 - async loadPage(page: number, append = false) { + // API 加载(分页:每次整页替换,不追加) + async loadPage(page: number) { const myToken = ++this.reqToken - if (append) { - this.loadingMore = true - } else { - this.loading = true - this.items = [] - } + this.loading = true + this.items = [] try { const json = await getArticles({ page, - size: this.size, sort: "newest", - brand_ids: this.selectedBrands, - collection_type: this.selectedCt, - season: this.selectedSeason, - year: this.selectedYear, - with_images: 6, + 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.total = json.total + this.lastPage = json.last_page this.page = page - this.items = append ? [...this.items, ...rows] : rows - - this.isEnd = page >= json.last_page || rows.length < this.size + this.items = rows } catch { if (myToken !== this.reqToken) return - if (!append) this.items = [] + this.items = [] } finally { - if (myToken === this.reqToken) { - this.loading = false - this.loadingMore = false - } + if (myToken === this.reqToken) this.loading = false } }, + // ===== 分页导航 ===== + // 生成窗口化的页码列表:始终含首尾页,当前页 ±1,其余用 "..." 折叠。 + get pageList() { + const total = this.lastPage + const cur = this.page + if (total <= 7) { + return Array.from({ length: total }, (_: any, i: number) => i + 1) + } + const pages: (number | "...")[] = [1] + const start = Math.max(2, cur - 1) + const end = Math.min(total - 1, cur + 1) + if (start > 2) pages.push("...") + for (let i = start; i <= end; i++) pages.push(i) + if (end < total - 1) pages.push("...") + pages.push(total) + return pages + }, + + 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) }, diff --git a/src/components/StreetSnap.astro b/src/components/StreetSnap.astro new file mode 100644 index 0000000..e3a470e --- /dev/null +++ b/src/components/StreetSnap.astro @@ -0,0 +1,348 @@ +--- +import type { RawStreetSnap } from "@/lib/api" +import Breadcrumb from "@/components/Breadcrumb.astro" +import { getI18n } from "@/i18n/utils" +import { href, ROUTES } from '@/lib/routes' +import { ssrBase } from "@/lib/api" + +interface Props { + snap: RawStreetSnap | null + error?: "missing" | "failed" | null +} + +const { snap, error = null } = Astro.props +const { locale, t } = getI18n(Astro) + +const full = (u: string): string => { + if (!u) return "" + if (/^https?:\/\//i.test(u)) return u + return ssrBase().replace(/\/$/, "") + u +} + +const images = (snap?.images ?? []) + .map((im) => ({ + id: im.id, + url: full(im.image || ""), + name: im.name || "", + })) + .filter((im) => im.url) + +const homeHref = href(locale, ROUTES.home) +const archiveHref = href(locale, ROUTES.streetSnaps) + +// theimpression 标题含字面 \t:地点 \t 场次,列表/详情统一以 " · " 呈现 +const displayTitle = (snap?.title || "").replace(/\t/g, " · ") +--- + +
    + { + snap ? ( +
    + + {displayTitle} + + +

    + {displayTitle} +

    + + {images.length > 0 && ( +
    + +
    + )} +
    + ) : ( +
    +

    + {error === "missing" ? t('No street snap ID specified') : t('Failed to load street snap. Please refresh or try later.')} +

    + + + ← {t('Back to Index')} + +
    + ) + } + + {snap && images.length > 0 && ( + + )} +
    + + + + diff --git a/src/components/StreetSnaps.astro b/src/components/StreetSnaps.astro new file mode 100644 index 0000000..75a00a6 --- /dev/null +++ b/src/components/StreetSnaps.astro @@ -0,0 +1,490 @@ +--- +import Breadcrumb from "@/components/Breadcrumb.astro" +import { getI18n } from '@/i18n/utils' +import { clientHref, ROUTES } from '@/lib/routes' +import { toAbs } from '@/lib/api' + +const { locale, t } = getI18n(Astro) + +// 年份筛选:近 10 年(含当年),末项兜底 +const currentYear = new Date().getFullYear() +const yearFloor = currentYear - 9 +const yearOptions: { value: number | string; label: string }[] = [] +for (let y = currentYear; y >= yearFloor; y--) { + yearOptions.push({ value: y, label: String(y) }) +} +yearOptions.push({ value: `before${yearFloor}`, label: `${yearFloor}年前` }) + +// 排序选项:image_count 默认(按图片数降序)/ year_desc / year_asc +const sortOptions: { value: string; label: string }[] = [ + { value: "image_count", label: t('Most photos') }, + { value: "year_desc", label: t('Newest year') }, + { value: "year_asc", label: t('Oldest year') }, +] +--- + +
    + +
    +
    + + {t('Street Style')} + +

    {t('All Street Snaps')}

    +
    +
    + + +
    + +
    + + +
    + + + + +
    +
    + + + + + + + + + +
    + + +
    +
    +
    + + + + + + diff --git a/src/components/pages/article.astro b/src/components/pages/article.astro index bbbe4b1..28994ec 100644 --- a/src/components/pages/article.astro +++ b/src/components/pages/article.astro @@ -1,5 +1,5 @@ --- -import type { RawArticle } from "@/lib/data" +import type { RawArticle } from "@/lib/api" import Breadcrumb from "@/components/Breadcrumb.astro" import { ssrBase } from "@/lib/api" diff --git a/src/components/pages/articles.astro b/src/components/pages/articles.astro deleted file mode 100644 index 8f20a4d..0000000 --- a/src/components/pages/articles.astro +++ /dev/null @@ -1,470 +0,0 @@ ---- -import { getReports, fmtDate } from "@/lib/data" -import { href, ROUTES } from '@/lib/routes' -import { getI18n } from "@/i18n/utils" - -// 文章展示创意布局页:复用你现有的 Layout 外壳。 -// 数据由数据层 getReports() 统一从后端公开接口拉取(见 src/lib/data.ts); -// 后端不可达时回落到内置示例,非对称创意布局结构完全不变。 - -const reports = await getReports(30) -const fmt = fmtDate -const { locale } = getI18n(Astro) -const itemBase = href(locale, "/items") ---- - -
    - -
    -
    - Runway Archive - SS23 — AW26 -
    -

    The Reports

    -
    -

    - 走秀趋势报告档案库。支持网格 / 杂志 / 时间轴三种创意视图自由切换,彩色配图由后端接口动态渲染。 -

    - ← Index -
    -
    - - -
    -
    - - - -
    - {reports.length} Reports -
    - - -
    - - -
    - - - - - - -
    -
    - - - -
    - - - -
    - - - - diff --git a/src/i18n/dictionary.ts b/src/i18n/dictionary.ts index 5c3994a..6e712d5 100644 --- a/src/i18n/dictionary.ts +++ b/src/i18n/dictionary.ts @@ -96,7 +96,19 @@ export const dict: Record = { 'language': { cn: '语言' }, - 'runway looks': { cn: '秀场造型' } + 'runway looks': { cn: '秀场造型' }, + + // ── 街拍 / Street Snaps ── + + 'street style': { cn: '街拍' }, + + 'street snaps': { cn: '街拍' }, + + 'all street snaps': { cn: '全部街拍' }, + + 'no street snap id specified': { cn: '未指定街拍 ID' }, + + 'failed to load street snap. please refresh or try later.': { cn: '加载街拍失败,请刷新或稍后再试' } }; diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index edb1434..06d7821 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -88,6 +88,7 @@ const headers = {
    {t('Latest Projects')} + {t('Street Style')} {t('About')} {t('Contact Us')} diff --git a/src/lib/api.ts b/src/lib/api.ts index d8a0904..9faa3d6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,7 +11,6 @@ // 与 BASE_API 完全分离,绝不回落到公开/运行期端口(避免内部全量数据外泄)。 // // - getBase():公开接口 base。DEV(astro dev)→ 线上域名;其余 → BASE_API(空则同源相对 /api)。 -// - publicBase():客户端运行期 base,仅 BASE_API(空 → 同源相对 /api)。 // - ssrBase():按需渲染(on-demand)详情取数 base,BASE_API > 本地 8090 回落。 // - ssgBase():构建期 Node 调 /api/ssg/*,只读 BASE_API_SSG。 @@ -34,7 +33,6 @@ export interface BrandEntry { name: string // 英文名(name_en 回落 name) nameCn?: string // 中文名(name_cn,可能为空) display?: string // 展示名:中文语境下为 "英文名 (中文名)",否则为英文名 - article_count: number } export interface ArticleItem { @@ -43,49 +41,33 @@ export interface ArticleItem { title: string cover: string brand_name: string - year: number - image_count: number - published_at: number - collection_type?: string - season?: string - season_code?: string - gallery: string[] + image_count: number // 该走秀图片总数(列表卡片 "N+" 角标用) + gallery: string[] // 由 images 映射而来的附图 URL 列表 } export interface ArticleQuery { page?: number - size?: number sort?: string - brand_ids?: string[] // 编码串数组,透传给后端解码 - collection_type?: string[] - season?: string[] - year?: string[] - with_images?: number + brandId?: string // 编码串;传入则附加 ?brand_id 查询参数 + collection?: string // 单值:rtw/menswear/couture/resort/pre_fall + season?: string // 单值:spring/fall + year?: string | number } export interface ArticlePage { data: ArticleItem[] - total: number last_page: number } // 后端文章原始结构(与 PublicArticle / PublicArticleDetail 对齐)。 export type RawArticle = { - id: string | number // 后端为 hashid 编码串(string);示例数据回落为数字 + id: string | number // 后端为 hashid 编码串(string) title: string - summary?: string - description?: string + description?: string // 详情正文(仅详情接口返回) cover: string brand_name: string brand_id?: string | number // hashid 编码串 - year: number - image_count: number - published_at: number - created_at?: number - source_url?: string - collection_type?: string - season?: string - season_code?: string + image_count?: number // 列表卡片 "N+" 角标用(仅列表接口返回) images?: { id?: number; image: string; name?: string }[] } @@ -120,17 +102,6 @@ export function toAbs(url: string): string { return /^https?:\/\//i.test(url) ? url : getBase() + url } -// 客户端运行期 base:仅 BASE_API(空 → 同源相对 /api)。 -export function publicBase(): string { - return (BASE_API || "").replace(/\/$/, "") -} - -export function toAbsPublic(url: string): string { - if (!url) return "" - if (/^https?:\/\//i.test(url)) return url - return publicBase() + url -} - // 按需渲染(on-demand SSR)详情 base:BASE_API > 本地 8090 回落。 export function ssrBase(): string { return (BASE_API || "http://localhost:8090").replace(/\/$/, "") @@ -166,59 +137,60 @@ function withLocale(params?: URLSearchParams, override?: string): URLSearchParam return p } +// ===================== 前端 JS 请求签名(反爬一层) ===================== +// 注意:这是「提高成本」而非「加密」。CLIENT_SIGN_SECRET 必须出现在前端 bundle 才能签名, +// 对浏览器可见;有决心的爬虫可反编译复刻。它用于拖慢 casual 爬虫,配合后端按 IP 限流才有效。 +// 必须与后端 client_sign.secret 完全一致。生产请改用随机长串并同步两端。 +const CLIENT_SIGN_SECRET = "dev-client-sign-secret-change-me" +const CLIENT_SIGN_TTL = 30 + +// 计算 HMAC-SHA256 签名。canonical = METHOD + "\n" + Path + "\n" + RawQuery + "\n" + ts + "\n" + nonce +// 与后端 middleware.ClientSign 的拼接方式严格一致(换行符分隔,顺序固定)。 +async function clientSign(method: string, fullPath: string, rawQuery: string): Promise<{ sig: string; ts: string; nonce: string }> { + const ts = String(Math.floor(Date.now() / 1000)) + const nonce = Math.random().toString(36).slice(2) + Date.now().toString(36) + const msg = [method, fullPath, rawQuery, ts, nonce].join("\n") + const enc = new TextEncoder() + const key = await crypto.subtle.importKey( + "raw", + enc.encode(CLIENT_SIGN_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ) + const buf = await crypto.subtle.sign("HMAC", key, enc.encode(msg)) + const sig = Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + return { sig, ts, nonce } +} + // ===================== 内部请求原语 ===================== // 通用请求原语:走 getBase() + /api/v1 + path(公开接口)。GET-only。 // 自动追加 ?locale=(当前语言),让后端按语言选列返回。 +// 每个请求附带前端 JS 签名(X-Sign / X-Sign-Ts / X-Sign-Nonce),供后端 ClientSign 中间件校验。 async function request(path: string, params?: URLSearchParams): Promise { const p = withLocale(params) const qs = p.toString() ? "?" + p.toString() : "" - const url = getBase() + "/api/v1" + path + qs - const res = await fetch(url) + const fullPath = "/api/v1" + path + const { sig, ts, nonce } = await clientSign("GET", fullPath, p.toString()) + const url = getBase() + fullPath + qs + const res = await fetch(url, { + headers: { + "X-Sign": sig, + "X-Sign-Ts": ts, + "X-Sign-Nonce": nonce, + }, + }) if (!res.ok) throw new Error(`API ${res.status} ${url}`) return (await res.json()) as T } -// ===================== 文章(/api/public/articles) ===================== +// ===================== 走秀(/api/public/runway-looks) ===================== -// 接口:GET /api/public/articles -// 原始文章列表(不分页映射),供数据层与各类 getter 复用。 -export async function getArticlesRaw(opts?: { - page?: number - size?: number - sort?: string - brand_ids?: string[] - brand_id?: string - collection_type?: string[] - season?: string[] - year?: string[] - with_images?: number - keyword?: string -}): Promise<{ data: RawArticle[]; total: number; last_page: number }> { - const p = new URLSearchParams() - p.set("page", String(opts?.page ?? 1)) - p.set("size", String(opts?.size ?? 24)) - if (opts?.sort) p.set("sort", opts.sort) - if (opts?.keyword) p.set("keyword", opts.keyword) - if (opts?.with_images) p.set("with_images", String(opts.with_images)) - if (opts?.brand_id) p.set("brand_id", opts.brand_id) - if (opts?.brand_ids?.length) p.set("brand_ids", opts.brand_ids.join(",")) - for (const v of opts?.collection_type ?? []) p.append("collection_type", v) - for (const v of opts?.season ?? []) p.append("season", v) - for (const v of opts?.year ?? []) p.append("year", v) - const j = await request<{ data?: any[]; total?: number; last_page?: number }>( - "/public/articles", - p - ) - return { - data: (j.data ?? []) as RawArticle[], - total: Number(j.total ?? (j.data?.length ?? 0)), - last_page: Number(j.last_page ?? 1), - } -} - -// 接口:GET /api/public/articles/:id -// 文章详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。 +// 接口:GET /api/public/runway-looks/:id +// 走秀详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数),也可显式传入。 // locale 显式传入时优先(按需渲染 SSR 用它避免并发串 locale),否则走 currentLocale()。 export async function fetchArticleById( id: number | string, @@ -230,7 +202,7 @@ export async function fetchArticleById( const p = withLocale(undefined, locale) const qs = p.toString() ? "?" + p.toString() : "" try { - const res = await fetch(`${b}/api/v1/public/articles/${id}${qs}`) + const res = await fetch(`${b}/api/v1/public/runway-looks/${id}${qs}`) if (!res.ok) return null const json: any = await res.json() return (json?.data ?? null) as RawArticle | null @@ -261,65 +233,36 @@ export async function getSsrArticle( } } -// 接口:GET /api/public/articles(经 getArticlesRaw 透传) -// 文章列表(RunwayLooks / shows 用):多选用 repeated params,并把 images 映射成 gallery。 +// 接口:GET /api/public/runway-looks +// 走秀列表(RunwayLooks / shows 用):单值筛选,brandId 走 ?brand_id 查询参数; +// 把后端 images[{image,name}] 映射成前端卡片用的 gallery(纯 URL 数组)。 export async function getArticles(q: ArticleQuery): Promise { - const { data, total, last_page } = await getArticlesRaw({ - page: q.page, - size: q.size, - sort: q.sort, - with_images: q.with_images, - brand_ids: q.brand_ids, - collection_type: q.collection_type, - season: q.season, - year: q.year, - }) - const mapped: ArticleItem[] = data.map((it: any) => ({ + const p = new URLSearchParams() + p.set("page", String(q.page ?? 1)) + if (q.sort) p.set("sort", q.sort) + if (q.brandId) p.set("brand_id", q.brandId) + if (q.collection) p.set("collection", q.collection) + if (q.season) p.set("season", q.season) + if (q.year) p.set("year", String(q.year)) + const j = await request<{ data?: any[]; last_page?: number }>("/public/runway-looks", p) + const mapped: ArticleItem[] = (j.data ?? []).map((it: any) => ({ ...it, gallery: Array.isArray(it.images) ? it.images.map((im: any) => im.image || im) : [], })) - return { data: mapped, total: Number(total ?? mapped.length), last_page: Number(last_page ?? 1) } -} - -// 接口:GET /api/public/articles(客户端运行期,base 走 BASE_API) -// articles.astro 客户端按需拉取:保留该页独有的单值参数形态。 -export async function fetchArticlesList(opts?: { - page?: number - size?: number - collection_type?: string - season?: string - brand_id?: string -}): Promise { - const base = publicBase() - if (!base) return [] - const p = new URLSearchParams({ page: String(opts?.page ?? 1), size: String(opts?.size ?? 30) }) - if (opts?.collection_type) p.set("collection_type", opts.collection_type) - if (opts?.season) p.set("season", opts.season) - if (opts?.brand_id) p.set("brand_id", opts.brand_id) - // 浏览器端按 URL path 自动推断当前语言并追加 ?locale= - p.set("locale", currentLocale()) - const res = await fetch(`${base}/api/v1/public/articles?${p.toString()}`) - if (!res.ok) throw new Error(`list ${res.status}`) - const json = await res.json() - return json?.data ?? [] + return { data: mapped, last_page: Number(j.last_page ?? 1) } } // ===================== 品牌(/api/public/brands) ===================== // 接口:GET /api/public/brands +// size 已写死 30(后端 brandDefaultSize),only_with_articles 已删除(字母索引始终只返回有档案品牌), +// 前端均不传;关键词搜索仍不过滤"必须有走秀档案"(后端对 keyword 跳过该限制)。 export async function getBrands(opts?: { - size?: number - only_with_articles?: number featured?: number keyword?: string letter?: string }): Promise { const qs = new URLSearchParams() - if (opts?.size) qs.set("size", String(opts.size)) - // 关键词搜索:用户按名字找品牌,不应被"必须有走秀档案"过滤。 - // 后端已对 keyword 跳过该限制,这里再显式置 0 双保险,使正在运行的后端也立即生效。 - const onlyWA = opts?.keyword ? 0 : (opts?.only_with_articles ?? 1) - qs.set("only_with_articles", String(onlyWA)) if (opts?.featured != null) qs.set("featured", String(opts.featured)) if (opts?.keyword) qs.set("keyword", opts.keyword) if (opts?.letter) qs.set("letter", opts.letter) @@ -334,7 +277,6 @@ export async function getBrands(opts?: { name: nameEn, nameCn, display, - article_count: Number(b.article_count ?? 0), } }) } @@ -447,10 +389,110 @@ export async function getSsgHotBrands(): Promise { name: nameEn, nameCn: (b.name_cn || "").toString(), display: nameEn, - article_count: Number(b.article_count ?? 0), } }) } +// ===================== 街拍(/api/public/street-snaps) ===================== + +export interface StreetSnapItem { + id: string // hashid 编码串 + title: string // 含字面 \t 分隔:地点 \t 场次 + cover: string + image_count: number + images: { image: string; name: string }[] // 列表附带的每篇前 N 张缩略图 +} + +// 列表响应(与走秀 ArticlePage 同构) +export interface StreetSnapPage { + data: StreetSnapItem[] + last_page: number +} + +// 街拍详情原始结构(与 PublicStreetSnapDetail 对齐)。 +export interface RawStreetSnap { + id: string + title: string + cover: string + images?: { id?: number; image: string; name?: string }[] +} + +// 接口:GET /api/public/street-snaps +// 街拍列表:年份过滤 + 排序(image_count 默认降序 / year_desc / year_asc)。 +// 分页 size 服务端固定 24,images 服务端固定 6,前端均不传。 +export async function getStreetSnaps(q: { + page?: number + year?: number | string + sort?: string +} = {}): Promise { + 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)) + 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), + title: it.title ?? "", + cover: it.cover ?? "", + image_count: Number(it.image_count ?? 0), + images: Array.isArray(it.images) ? it.images.map((im: any) => ({ image: im.image || im, name: im.name || "" })) : [], + })) + return { data: mapped, last_page: Number(j.last_page ?? 1) } +} + +// 接口:GET /api/public/street-snaps/:id +// 街拍详情(含完整图片集)。base 默认走 ssrBase()(运行期按需渲染取数)。 +export async function fetchStreetSnapById( + id: number | string, + base: string = ssrBase(), + locale?: string +): Promise { + const b = base.replace(/\/$/, "") + if (!b) return null + const p = withLocale(undefined, locale) + const qs = p.toString() ? "?" + p.toString() : "" + try { + const res = await fetch(`${b}/api/v1/public/street-snaps/${id}${qs}`) + if (!res.ok) return null + const json: any = await res.json() + return (json?.data ?? null) as RawStreetSnap | null + } catch { + return null + } +} + +export type SsrStreetSnapResult = { + snap: RawStreetSnap | null + error: "missing" | "failed" | null +} + +// 服务端按需渲染(on-demand)取街拍详情:在 fetchStreetSnapById 之上包揽 +// 「缺 id / 取数失败 / 成功」三态,.astro 页面只调这一行即可。 +export async function getSsrStreetSnap( + id: string | null | undefined, + locale?: string +): Promise { + if (!id) return { snap: null, error: "missing" } + try { + const snap = await fetchStreetSnapById(id, ssrBase(), locale) + return { snap, error: snap ? null : "failed" } + } catch { + return { snap: null, error: "failed" } + } +} + +// 接口:GET /api/v1/ssg/street-snaps/popular(SSG 内部端口,构建期 Node 调用) +// 首页「Street Style」热门街拍(按图片数前 N,返回完整对象含封面)。 +export async function getSsgStreetSnapPopular(limit = 12): Promise { + const j = await ssgRequest<{ data?: any[] }>(`/api/v1/ssg/street-snaps/popular?limit=${limit}`) + return (j?.data ?? []).map((b: any) => ({ + id: String(b.id), + title: b.title ?? "", + cover: b.cover ?? "", + image_count: Number(b.image_count ?? 0), + images: [], + })) +} + diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 2f48d70..07f676b 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -17,10 +17,12 @@ export const ROUTES = { login: '/login', register: '/register', runwayLooks: '/runway-looks', + // 街拍 + streetSnaps: '/street-snaps', // 动态路由(工厂函数) item: (id: string | number) => `/item/${id}`, article: (id: string | number) => `/article?id=${id}`, - articlesByBrand: (id: string | number) => `/articles?brand_id=${id}`, + streetSnap: (id: string | number) => `/street-snaps/${id}`, // runwayLooksDetail: (id: string | number) => `/runway-looks/${id}`, } as const; diff --git a/src/pages/cn/street-snaps/[id].astro b/src/pages/cn/street-snaps/[id].astro new file mode 100644 index 0000000..00792fd --- /dev/null +++ b/src/pages/cn/street-snaps/[id].astro @@ -0,0 +1,15 @@ +--- +import Layout from "@/layouts/Layout.astro" +import StreetSnap from "@/components/StreetSnap.astro" +import { getSsrStreetSnap } from "@/lib/api" + +// 按需 SSR:服务端按 :id 取数并渲染(保留 /cn/street-snaps/:id 链接) +export const prerender = false + +const { id } = Astro.params +const { snap, error } = await getSsrStreetSnap(id, Astro.currentLocale) +--- + + + + diff --git a/src/pages/cn/street-snaps/index.astro b/src/pages/cn/street-snaps/index.astro new file mode 100644 index 0000000..8fb1479 --- /dev/null +++ b/src/pages/cn/street-snaps/index.astro @@ -0,0 +1,7 @@ +--- +import Layout from '@/layouts/Layout.astro' +import StreetSnaps from '@/components/StreetSnaps.astro' +--- + + + diff --git a/src/pages/en/street-snaps/[id].astro b/src/pages/en/street-snaps/[id].astro new file mode 100644 index 0000000..ba87883 --- /dev/null +++ b/src/pages/en/street-snaps/[id].astro @@ -0,0 +1,15 @@ +--- +import Layout from "@/layouts/Layout.astro" +import StreetSnap from "@/components/StreetSnap.astro" +import { getSsrStreetSnap } from "@/lib/api" + +// 按需 SSR:服务端按 :id 取数并渲染(保留 /en/street-snaps/:id 链接) +export const prerender = false + +const { id } = Astro.params +const { snap, error } = await getSsrStreetSnap(id, Astro.currentLocale) +--- + + + + diff --git a/src/pages/en/street-snaps/index.astro b/src/pages/en/street-snaps/index.astro new file mode 100644 index 0000000..8fb1479 --- /dev/null +++ b/src/pages/en/street-snaps/index.astro @@ -0,0 +1,7 @@ +--- +import Layout from '@/layouts/Layout.astro' +import StreetSnaps from '@/components/StreetSnaps.astro' +--- + + +