19
.workbuddy/memory/2026-08-19.md
Normal file
19
.workbuddy/memory/2026-08-19.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# 2026-08-19
|
||||||
|
|
||||||
|
## 修复 RunwayLooksDetail.astro 的红线(TS 严格模式报错)
|
||||||
|
- 文件:`src/components/RunwayLooksDetail.astro`(一个 Alpine 灯箱详情组件)。
|
||||||
|
- 根因:项目 `tsconfig.json` 继承 `astro/tsconfigs/strict`(含 `noImplicitThis`)。该文件 `<script>` 里有两类红线:
|
||||||
|
1. **`this` 隐式 any**(ts2683):Alpine 组件 `Alpine.data("articleView", () => ({...}))` 的方法里直接用 `this.xxx`,无类型注解。
|
||||||
|
2. **DOM 属性类型不匹配**(ts2339/2531):`document.querySelector(...)` 返回 `Element` 却取 `.dataset/.offsetTop/.style`;`getElementById("lb-image")` 取 `.src/.alt`(需 `HTMLImageElement`);`querySelectorAll(...)` 结果按 `Element` 取 `.dataset/.style`。
|
||||||
|
- 修复:
|
||||||
|
- 新增 `type ArticleView = {...}` 接口,给 `Alpine.data` 回调标注返回类型 `(): ArticleView => ({...})`,使对象字面量被上下文类型化,`this` 推断为 `ArticleView`,红线 1 消除。
|
||||||
|
- 给 DOM 查询补正确元素类型:`getImageElement` 返回 `HTMLElement | null`(`querySelector<HTMLElement>`);`getElementById("lb-image") as HTMLImageElement | null`;`getElementById("lb-thumbs") as HTMLElement | null` + `querySelector<HTMLElement>`;`querySelectorAll<HTMLElement>`;`entry.target as HTMLElement`。红线 2 消除。
|
||||||
|
- 验证:把 `<script>` 抽成临时 `.ts`(注入 `import type * as AlpineNS from "alpinejs"; declare global { const Alpine: AlpineNS.Alpine }`),用 `node node_modules/typescript/bin/tsc --noEmit --strict --skipLibCheck --ignoreConfig --target es2020 --lib dom,es2020 --module esnext --moduleResolution bundler` 检查,**零报错**;`npm run build` 通过。
|
||||||
|
|
||||||
|
## 项目小坑补充(复用 Astro 脚本类型检查)
|
||||||
|
- 本仓库**未安装 `typescript`**(也不在 package.json),`npx tsc` 会误装错误的 `tsc@2.0.3` 包。需 `npm i typescript --no-save` 后再用 `node node_modules/typescript/bin/tsc` 检查;`--moduleResolution node`(node10) 在新版 TS 已移除,改用 `bundler`;命令行显式传文件时需加 `--ignoreConfig`(否则报 TS5112 因存在 tsconfig.json)。
|
||||||
|
- `astro check` 也未安装。临时抽 `<script>` + tsc --strict 是可接受的替代验证方式。
|
||||||
|
- 提醒:同类 Alpine 组件(`RunwayLooks.astro`、`src/components/pages/article.astro` 等)若后续也出现 `this` 红线,套用同样的「接口标注返回类型」修法即可。
|
||||||
|
|
||||||
|
## 用户偏好(本次交互印证)
|
||||||
|
- 用户倾向于「直接帮我修好」,而非只给方案;遇到红线/报错类诉求,动手修复并实证验证(构建/类型检查)比单纯解释更受认可。
|
||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import { useTranslations, DEFAULT_LOCALE, type Locale } from "@/i18n/utils";
|
import { useTranslations, DEFAULT_LOCALE, type Locale, getI18n } from "@/i18n/utils";
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */
|
/** i18n key:英文原文,如 'Home' / 'Runway Archive';中文环境自动取字典翻译 */
|
||||||
@ -14,12 +14,15 @@ interface Props {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { items = [], locale } = Astro.props;
|
const {t, locale, getRelativeLocaleUrl} = getI18n(Astro);
|
||||||
const currentLocale = (locale ?? Astro.currentLocale ?? DEFAULT_LOCALE) as Locale;
|
const { items = [] } = Astro.props;
|
||||||
const { t } = useTranslations(currentLocale);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<nav class="crumb" aria-label="Breadcrumb">
|
<nav class="crumb" aria-label="Breadcrumb">
|
||||||
|
<Fragment>
|
||||||
|
<a href={getRelativeLocaleUrl(locale, '/')} class="crumb-link">{t('Home')}</a>
|
||||||
|
<span class="crumb-sep" aria-hidden="true">/</span>
|
||||||
|
</Fragment>
|
||||||
{
|
{
|
||||||
items.map((it) => (
|
items.map((it) => (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
|||||||
@ -3,10 +3,9 @@ import Layout from "@/layouts/Layout.astro"
|
|||||||
import Breadcrumb from "@/components/Breadcrumb.astro"
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
|
||||||
import { getI18n } from '@/i18n/utils'
|
import { getI18n } from '@/i18n/utils'
|
||||||
import { getRelativeLocaleUrl } from 'astro:i18n'
|
|
||||||
|
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro)
|
const { locale, t, getRelativeLocaleUrl } = getI18n(Astro)
|
||||||
|
|
||||||
let sidebarBrands: BrandEntry[] = []
|
let sidebarBrands: BrandEntry[] = []
|
||||||
let yearChips: number[] = []
|
let yearChips: number[] = []
|
||||||
@ -32,13 +31,8 @@ if (!yearChips.length) yearChips = yearSeed
|
|||||||
<!-- 顶部:极简眉头 -->
|
<!-- 顶部:极简眉头 -->
|
||||||
<header class="showshead">
|
<header class="showshead">
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb
|
<Breadcrumb>
|
||||||
items={[
|
<span slot="current">{t('Runway looks')}</span>
|
||||||
{ label: t('Home'), href: '/' },
|
|
||||||
{ label: 'Runway Archive', href: '/shows' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<span slot="current" x-text="crumbYear">All shows</span>
|
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
<h1 class="showsh1">All Shows</h1>
|
<h1 class="showsh1">All Shows</h1>
|
||||||
</div>
|
</div>
|
||||||
@ -194,32 +188,26 @@ if (!yearChips.length) yearChips = yearSeed
|
|||||||
|
|
||||||
<!-- Card List -->
|
<!-- Card List -->
|
||||||
<template x-for="it in items" :key="it.id">
|
<template x-for="it in items" :key="it.id">
|
||||||
<a class="showscard" :href="`${locale}/runway-looks/${it.id}`" :aria-label="`${it.brand_name} ${it.title}`">
|
<a class="showscard" :href="`/${locale}/runway-looks/${it.id}`" :aria-label="`${it.brand_name} ${it.title}`">
|
||||||
<div class="showscard-left">
|
<div class="showscard-left">
|
||||||
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
<div class="showscard-img" :class="{ 'is-loaded': it._imgLoaded }">
|
||||||
<span class="showsspin" aria-hidden="true" x-html="SPIN_SVG"></span>
|
<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 }" />
|
<img :src="toAbsUrl(it.cover)" :alt="it.title" loading="lazy" @load="it._imgLoaded = true" @error="it._imgLoaded = true" :class="{ 'is-loaded': it._imgLoaded }" />
|
||||||
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="showscard-right">
|
<div class="showscard-right">
|
||||||
<div class="showscard-info">
|
<div class="showscard-info">
|
||||||
<h3 class="showscard-title" x-text="it.title"></h3>
|
<h3 class="showscard-title" x-text="`${it.brand_name} / ${it.title}`"></h3>
|
||||||
<p class="showscard-meta" x-text="`${it.season_code || it.year || ''} · ${ctLabel(it.collection_type)} · ${it.brand_name}`"></p>
|
|
||||||
<div class="showscard-meta-foot">
|
|
||||||
<span class="showscard-meta-line"><span x-html="ICON_USER"></span><span x-text="it.brand_name"></span></span>
|
|
||||||
<span class="showscard-meta-line"><span x-html="ICON_DATE"></span><span x-text="fmtDate(it.published_at)"></span></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="showscard-strip">
|
||||||
<div class="showscard-strip">
|
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
||||||
<template x-for="(slot, idx) in getCardSlots(it)" :key="idx">
|
<div class="showscard-thumb" :class="{ 'placeholder': !slot.src, 'is-loaded': slot.loaded }">
|
||||||
<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>
|
||||||
<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 }" />
|
||||||
<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>
|
||||||
<span class="showscard-thumb-more" x-show="slot.isLast && slot.moreLabel" x-text="slot.moreLabel"></span>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@ -643,15 +631,6 @@ if (!yearChips.length) yearChips = yearSeed
|
|||||||
return this.allBrands.length > 60
|
return this.allBrands.length > 60
|
||||||
},
|
},
|
||||||
|
|
||||||
// 面包屑末级:未选年份时显示默认的「2026 Edition」,选中后显示对应年份
|
|
||||||
get crumbYear() {
|
|
||||||
if (this.selectedYear.length) {
|
|
||||||
const ys = [...this.selectedYear].sort()
|
|
||||||
return ys.length === 1 ? `${ys[0]} Edition` : `${ys.join(" / ")}`
|
|
||||||
}
|
|
||||||
return "2026 Edition"
|
|
||||||
},
|
|
||||||
|
|
||||||
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
|
// 骨架屏数量与首屏 data 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
|
||||||
get skeletonCount() {
|
get skeletonCount() {
|
||||||
return this.size
|
return this.size
|
||||||
@ -833,12 +812,6 @@ if (!yearChips.length) yearChips = yearSeed
|
|||||||
|
|
||||||
// 辅助辅助工具函数
|
// 辅助辅助工具函数
|
||||||
toAbsUrl(url: string) { return toAbs(url) },
|
toAbsUrl(url: string) { return toAbs(url) },
|
||||||
isNewAdded(pubAt?: number) { return Date.now() / 1000 - Number(pubAt || 0) < 60 * 60 * 24 * 14 },
|
|
||||||
fmtDate(ts: number) {
|
|
||||||
if (!ts) return ""
|
|
||||||
const d = new Date(ts * 1000)
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
|
||||||
},
|
|
||||||
ctLabel(ct?: string) {
|
ctLabel(ct?: string) {
|
||||||
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall" }
|
const map: Record<string, string> = { rtw: "Ready-to-Wear", menswear: "Menswear", couture: "Couture", resort: "Resort", pre_fall: "Pre-Fall" }
|
||||||
return ct && map[ct] ? map[ct] : (ct || "")
|
return ct && map[ct] ? map[ct] : (ct || "")
|
||||||
|
|||||||
518
src/components/RunwayLooksDetail.astro
Normal file
518
src/components/RunwayLooksDetail.astro
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
---
|
||||||
|
import type { RawArticle } from "@/lib/data"
|
||||||
|
import Breadcrumb from "@/components/Breadcrumb.astro"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
article: RawArticle | null
|
||||||
|
locale?: "en" | "cn"
|
||||||
|
apiBase?: string
|
||||||
|
error?: "missing" | "failed" | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
article,
|
||||||
|
locale = "en",
|
||||||
|
apiBase = "",
|
||||||
|
error = null,
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
|
const full = (u: string): string => {
|
||||||
|
if (!u) return ""
|
||||||
|
if (/^https?:\/\//i.test(u)) return u
|
||||||
|
return apiBase.replace(/\/$/, "") + u
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = (article?.images ?? [])
|
||||||
|
.map((im) => ({
|
||||||
|
id: im.id,
|
||||||
|
url: full(im.image || ""),
|
||||||
|
name: im.name || "",
|
||||||
|
}))
|
||||||
|
.filter((im) => im.url)
|
||||||
|
|
||||||
|
const descParas = (article?.description || "")
|
||||||
|
.split(/\n\n+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const L = {
|
||||||
|
en: {
|
||||||
|
back: "← Back to Index",
|
||||||
|
missing: "No article ID specified",
|
||||||
|
failed: "Failed to load article. Please refresh or try later.",
|
||||||
|
close: "Close",
|
||||||
|
previous: "Previous image",
|
||||||
|
next: "Next image",
|
||||||
|
zoomTip: "Click image to zoom · Scroll to view · Esc to close",
|
||||||
|
},
|
||||||
|
cn: {
|
||||||
|
back: "← 返回首页",
|
||||||
|
missing: "未指定文章 ID",
|
||||||
|
failed: "加载文章失败,请刷新或稍后再试",
|
||||||
|
close: "关闭",
|
||||||
|
previous: "上一张",
|
||||||
|
next: "下一张",
|
||||||
|
zoomTip: "点击图片放大 · 上下滚动查看 · Esc 关闭",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const lbl = L[locale === "cn" ? "cn" : "en"]
|
||||||
|
const backLabel = lbl.back.replace("← ", "")
|
||||||
|
|
||||||
|
const homeHref = locale === "cn" ? "/cn" : "/en/"
|
||||||
|
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
|
||||||
|
|
||||||
|
const crumbs: { label: string; href: string }[] = [
|
||||||
|
{
|
||||||
|
label: "Runway Looks",
|
||||||
|
href: archiveHref,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (article?.brand_id) {
|
||||||
|
crumbs.push({
|
||||||
|
label: article.brand_name || "Brand",
|
||||||
|
href: `${archiveHref}?brand_id=${article.brand_id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="article-view"
|
||||||
|
x-data="articleView()"
|
||||||
|
class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
article ? (
|
||||||
|
<article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
|
||||||
|
<Breadcrumb items={crumbs}>
|
||||||
|
<span slot="current" class="max-w-[60vw] truncate">
|
||||||
|
{article.title}
|
||||||
|
</span>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{images.length > 0 && (
|
||||||
|
<section class="mt-16">
|
||||||
|
<div
|
||||||
|
id="article-gallery"
|
||||||
|
class="grid grid-cols-2 md:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
{images.map((im, i) => (
|
||||||
|
<figure
|
||||||
|
data-image-index={i}
|
||||||
|
data-image-url={im.url}
|
||||||
|
data-image-name={im.name}
|
||||||
|
@click={`openLb(${i})`}
|
||||||
|
class="bg-[#f2f2f2] cursor-zoom-in group"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={im.url}
|
||||||
|
alt={im.name}
|
||||||
|
loading={i < 6 ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{im.name && (
|
||||||
|
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2">
|
||||||
|
{im.name}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{descParas.length > 0 && (
|
||||||
|
<section class="mt-16 max-w-[720px] space-y-5 text-[15px] leading-[1.8] text-[#222]">
|
||||||
|
{descParas.map((p) => (
|
||||||
|
<p>{p}</p>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
) : (
|
||||||
|
<div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
|
||||||
|
<p class="font-mono text-sm text-[#888] uppercase tracking-widest mb-4">
|
||||||
|
{error === "missing" ? lbl.missing : lbl.failed}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={homeHref}
|
||||||
|
class="text-[12px] font-mono uppercase tracking-[0.2em] border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← {backLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{article && images.length > 0 && (
|
||||||
|
<div
|
||||||
|
x-show="lbOpen"
|
||||||
|
x-cloak
|
||||||
|
@keydown.window.escape="closeLb()"
|
||||||
|
@keydown.window.arrow-right="nextLb()"
|
||||||
|
@keydown.window.arrow-left="prevLb()"
|
||||||
|
class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-5 left-1/2 -translate-x-1/2 z-30 font-mono text-[12px] font-semibold tracking-[0.14em] text-[#111] bg-white/75 border border-black/10 px-3 py-1.5 rounded-[6px] backdrop-blur-md"
|
||||||
|
x-text="`${lbIndex + 1} / ${imageCount}`"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="closeLb()"
|
||||||
|
class="absolute top-[18px] right-[18px] z-30 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:bg-black/5 hover:border-black/30 transition-all cursor-pointer"
|
||||||
|
aria-label={lbl.close}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
id="lb-thumbs"
|
||||||
|
class="w-[76px] sm:w-[118px] flex-none overflow-y-auto overflow-x-hidden relative scroll-smooth p-[32px_8px] sm:p-[38px_18px_38px_20px] border-r border-black/10 bg-gradient-to-b from-black/5 to-transparent no-scrollbar"
|
||||||
|
>
|
||||||
|
{images.map((im, i) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-lb-index={i}
|
||||||
|
data-thumb-url={im.url}
|
||||||
|
@click={`showLb(${i})`}
|
||||||
|
class="lb-thumb relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200 opacity-40 hover:opacity-85 bg-[#eee] bg-cover bg-center"
|
||||||
|
x-bind:class="lbIndex === Number($el.dataset.lbIndex)
|
||||||
|
? 'opacity-100 scale-[1.03] ring-2 ring-[#111]'
|
||||||
|
: ''"
|
||||||
|
aria-label={`${i + 1}`}
|
||||||
|
>
|
||||||
|
<span class="absolute left-1 top-1 z-10 font-mono text-[8px] sm:text-[9px] font-semibold tracking-widest text-white px-1 py-0.5 rounded-[3px] bg-black/55 backdrop-blur-sm">
|
||||||
|
{String(i + 1).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="prevLb()"
|
||||||
|
class="absolute left-3 sm:left-5 top-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||||
|
aria-label={lbl.previous}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 14l6-6 6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="nextLb()"
|
||||||
|
class="absolute left-3 sm:left-5 bottom-2 z-20 w-[52px] sm:w-[72px] h-[30px] sm:h-[34px] border border-black/15 bg-white/80 rounded-[6px] backdrop-blur-md flex items-center justify-center text-[#111] hover:bg-black/5 transition-all cursor-pointer"
|
||||||
|
aria-label={lbl.next}
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
|
<path d="M6 10l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="prevLb()"
|
||||||
|
class="absolute left-[82px] sm:left-[130px] top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||||
|
aria-label={lbl.previous}
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
|
<path d="M15 6l-6 6 6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click.stop="nextLb()"
|
||||||
|
class="absolute right-2 sm:right-5 top-1/2 -translate-y-1/2 z-20 w-10 h-10 rounded-full border border-black/15 bg-white/75 text-[#111] flex items-center justify-center backdrop-blur-md hover:scale-105 hover:border-black/30 transition-all cursor-pointer"
|
||||||
|
aria-label={lbl.next}
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 stroke-current fill-none stroke-2" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 6l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="lb-main"
|
||||||
|
@click.self="closeLb()"
|
||||||
|
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
|
||||||
|
x-bind:class="lbZoomed
|
||||||
|
? 'overflow-y-auto overflow-x-hidden items-start'
|
||||||
|
: 'items-center'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
id="lb-image"
|
||||||
|
src=""
|
||||||
|
alt=""
|
||||||
|
@click="toggleZoom()"
|
||||||
|
decoding="async"
|
||||||
|
class="block select-none transition-opacity duration-200"
|
||||||
|
x-bind:class="lbZoomed
|
||||||
|
? 'w-full max-w-full h-auto max-h-none cursor-zoom-out'
|
||||||
|
: 'max-w-full max-h-full object-contain cursor-zoom-in'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute left-0 right-0 bottom-11 z-20 text-center text-black/60 font-mono text-[11px] uppercase tracking-[0.18em] pointer-events-none px-6"
|
||||||
|
x-text="currentImageName"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="!lbZoomed"
|
||||||
|
class="absolute left-0 right-0 bottom-4 z-20 text-center text-black/45 font-mono text-[11px] tracking-[0.14em] pointer-events-none px-6 transition-opacity duration-200"
|
||||||
|
>
|
||||||
|
{lbl.zoomTip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// type ArticleView = {
|
||||||
|
// lbOpen: boolean
|
||||||
|
// lbIndex: number
|
||||||
|
// lbZoomed: boolean
|
||||||
|
// touchStartX: number
|
||||||
|
// touchStartY: number
|
||||||
|
// imageCount: number
|
||||||
|
// currentImageName: string
|
||||||
|
// init(): void
|
||||||
|
// getImageElement(index: number): HTMLElement | null
|
||||||
|
// getImageUrl(index: number): string
|
||||||
|
// getImageName(index: number): string
|
||||||
|
// openLb(index?: number): void
|
||||||
|
// closeLb(): void
|
||||||
|
// showLb(index: number): void
|
||||||
|
// nextLb(): void
|
||||||
|
// prevLb(): void
|
||||||
|
// normalizeIndex(index: number): number
|
||||||
|
// updateLightboxImage(): void
|
||||||
|
// toggleZoom(): void
|
||||||
|
// scrollThumbIntoView(): void
|
||||||
|
// preloadAround(): void
|
||||||
|
// initThumbLazyLoading(): void
|
||||||
|
// handleTouchStart(event: TouchEvent): void
|
||||||
|
// handleTouchEnd(event: TouchEvent): void
|
||||||
|
// }
|
||||||
|
|
||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
Alpine.data("articleView", () => ({
|
||||||
|
lbOpen: false,
|
||||||
|
lbIndex: 0,
|
||||||
|
lbZoomed: false,
|
||||||
|
touchStartX: 0,
|
||||||
|
touchStartY: 0,
|
||||||
|
imageCount: 0,
|
||||||
|
currentImageName: "",
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.imageCount = document.querySelectorAll(
|
||||||
|
"#article-gallery [data-image-index]"
|
||||||
|
).length
|
||||||
|
|
||||||
|
this.initThumbLazyLoading()
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageElement(index: number) {
|
||||||
|
return document.querySelector<HTMLElement>(
|
||||||
|
`#article-gallery [data-image-index="${index}"]`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageUrl(index: number) {
|
||||||
|
return this.getImageElement(index)?.dataset.imageUrl || ""
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageName(index: number) {
|
||||||
|
return this.getImageElement(index)?.dataset.imageName || ""
|
||||||
|
},
|
||||||
|
|
||||||
|
openLb(index = 0) {
|
||||||
|
if (!this.imageCount) return
|
||||||
|
|
||||||
|
this.lbIndex = this.normalizeIndex(index)
|
||||||
|
this.lbZoomed = false
|
||||||
|
this.lbOpen = true
|
||||||
|
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
|
||||||
|
this.updateLightboxImage()
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.scrollThumbIntoView()
|
||||||
|
this.preloadAround()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
closeLb() {
|
||||||
|
this.lbOpen = false
|
||||||
|
this.lbZoomed = false
|
||||||
|
document.body.style.overflow = ""
|
||||||
|
},
|
||||||
|
|
||||||
|
showLb(index: number) {
|
||||||
|
if (!this.imageCount) return
|
||||||
|
|
||||||
|
this.lbIndex = this.normalizeIndex(index)
|
||||||
|
this.lbZoomed = false
|
||||||
|
|
||||||
|
this.updateLightboxImage()
|
||||||
|
|
||||||
|
const main = document.getElementById("lb-main")
|
||||||
|
|
||||||
|
if (main) {
|
||||||
|
main.scrollTop = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.scrollThumbIntoView()
|
||||||
|
this.preloadAround()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
nextLb() {
|
||||||
|
this.showLb(this.lbIndex + 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
prevLb() {
|
||||||
|
this.showLb(this.lbIndex - 1)
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeIndex(index: number) {
|
||||||
|
if (!this.imageCount) return 0
|
||||||
|
return ((index % this.imageCount) + this.imageCount) % this.imageCount
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLightboxImage() {
|
||||||
|
const image = document.getElementById("lb-image") as HTMLImageElement | null
|
||||||
|
|
||||||
|
if (!image) return
|
||||||
|
|
||||||
|
const url = this.getImageUrl(this.lbIndex)
|
||||||
|
const name = this.getImageName(this.lbIndex)
|
||||||
|
|
||||||
|
image.src = url
|
||||||
|
image.alt = name
|
||||||
|
this.currentImageName = name
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleZoom() {
|
||||||
|
this.lbZoomed = !this.lbZoomed
|
||||||
|
|
||||||
|
const main = document.getElementById("lb-main")
|
||||||
|
|
||||||
|
if (main) {
|
||||||
|
main.scrollTop = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollThumbIntoView() {
|
||||||
|
const thumbs = document.getElementById("lb-thumbs") as HTMLElement | null
|
||||||
|
|
||||||
|
if (!thumbs) return
|
||||||
|
|
||||||
|
const active = thumbs.querySelector<HTMLElement>(
|
||||||
|
`[data-lb-index="${this.lbIndex}"]`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
const target =
|
||||||
|
active.offsetTop -
|
||||||
|
(thumbs.clientHeight - active.clientHeight) / 2
|
||||||
|
|
||||||
|
thumbs.scrollTo({
|
||||||
|
top: Math.max(0, target),
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
preloadAround() {
|
||||||
|
const indexes = [
|
||||||
|
this.lbIndex,
|
||||||
|
this.normalizeIndex(this.lbIndex - 1),
|
||||||
|
this.normalizeIndex(this.lbIndex + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
indexes.forEach((index: number) => {
|
||||||
|
const url = this.getImageUrl(index)
|
||||||
|
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
const image = new Image()
|
||||||
|
image.decoding = "async"
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
initThumbLazyLoading() {
|
||||||
|
const thumbs = document.querySelectorAll<HTMLElement>(
|
||||||
|
"#lb-thumbs [data-thumb-url]"
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(thumbs)
|
||||||
|
if (!thumbs.length) return
|
||||||
|
|
||||||
|
if (!("IntersectionObserver" in window)) {
|
||||||
|
thumbs.forEach((thumb) => {
|
||||||
|
thumb.style.backgroundImage =
|
||||||
|
`url("${thumb.dataset.thumbUrl}")`
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries, obs) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return
|
||||||
|
|
||||||
|
const element = entry.target as HTMLElement
|
||||||
|
const url = element.dataset.thumbUrl
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
element.style.backgroundImage = `url("${url}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
obs.unobserve(element)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: document.getElementById("lb-thumbs"),
|
||||||
|
rootMargin: "300px",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
thumbs.forEach((thumb) => observer.observe(thumb))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -52,7 +52,6 @@ const backLabel = lbl.back.replace("← ", "")
|
|||||||
const homeHref = locale === "cn" ? "/cn" : "/en/"
|
const homeHref = locale === "cn" ? "/cn" : "/en/"
|
||||||
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
|
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
|
||||||
const crumbs: { label: string; href: string }[] = [
|
const crumbs: { label: string; href: string }[] = [
|
||||||
{ label: "Home", href: homeHref },
|
|
||||||
{ label: "Runway Looks", href: archiveHref },
|
{ label: "Runway Looks", href: archiveHref },
|
||||||
]
|
]
|
||||||
if (article?.brand_id) {
|
if (article?.brand_id) {
|
||||||
@ -196,7 +195,6 @@ if (article?.brand_id) {
|
|||||||
<!-- Alpine.js 业务逻辑:仅负责灯箱交互,图片数据由服务端注入 -->
|
<!-- Alpine.js 业务逻辑:仅负责灯箱交互,图片数据由服务端注入 -->
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
alert(1212)
|
|
||||||
Alpine.data('articleView', (images = []) => ({
|
Alpine.data('articleView', (images = []) => ({
|
||||||
images,
|
images,
|
||||||
lbOpen: false,
|
lbOpen: false,
|
||||||
|
|||||||
@ -37,6 +37,8 @@ export const dict: Record<string, TranslationEntry> = {
|
|||||||
|
|
||||||
// ── 页脚 / Footer ──
|
// ── 页脚 / Footer ──
|
||||||
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
'© 2026 PORTFOLIO STUDIO. ALL RIGHTS RESERVED.': { cn: '© 2026 PORTFOLIO STUDIO. 保留所有权利。' },
|
||||||
|
|
||||||
|
'Runway looks': {cn: '秀场造型'}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 把 {name} 形式的占位符替换成参数值。 */
|
/** 把 {name} 形式的占位符替换成参数值。 */
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { getLocaleByPath } from 'astro:i18n';
|
import { getLocaleByPath, getRelativeLocaleUrl } from 'astro:i18n';
|
||||||
import { DEFAULT_LOCALE, LOCALES, type Locale } from '@/i18n/config';
|
import { DEFAULT_LOCALE, LOCALES, type Locale } from '@/i18n/config';
|
||||||
import { translate } from '@/i18n/dictionary';
|
import { translate } from '@/i18n/dictionary';
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ export function useTranslations(locale: Locale) {
|
|||||||
export function getI18n(Astro: { currentLocale?: string | undefined }) {
|
export function getI18n(Astro: { currentLocale?: string | undefined }) {
|
||||||
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE;
|
const locale = (Astro.currentLocale as Locale) ?? DEFAULT_LOCALE;
|
||||||
const { t } = useTranslations(locale);
|
const { t } = useTranslations(locale);
|
||||||
return { locale, t };
|
return { locale, t, getRelativeLocaleUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { LOCALES, DEFAULT_LOCALE };
|
export { LOCALES, DEFAULT_LOCALE };
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Layout from "@/layouts/Layout.astro"
|
import Layout from "@/layouts/Layout.astro"
|
||||||
import Article from "@/components/pages/article.astro"
|
import RunwayLookDetail from "@/components/RunwayLooksDetail.astro"
|
||||||
import { fetchArticleById } from "@/lib/data"
|
import { fetchArticleById } from "@/lib/data"
|
||||||
|
|
||||||
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
|
||||||
@ -23,5 +23,5 @@ if (!id) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<Article article={article} locale="en" apiBase={API} error={error} />
|
<RunwayLookDetail article={article} locale="en" apiBase={API} error={error} />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
Reference in New Issue
Block a user