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

This commit is contained in:
toom1996
2026-08-18 20:33:56 +08:00
parent c592b9a473
commit d14dd7e2db
13 changed files with 1175 additions and 187 deletions

View File

@ -1,7 +1,7 @@
# 2026-08-16 # 2026-08-16
## i18n 取语言方式重构 ## i18n 取语言方式重构
- 项目 i18n 已配置(defaultLocale: 'en', locales: ['en','cn'], prefixDefaultLocale: false)。 - 项目 i18n 已配置(defaultLocale: 'en', locales: ['en','cn'])。**注意:本文件撰写时误记 prefixDefaultLocale 为 false;当前 astro.config.mjs 实际为 `prefixDefaultLocale: true`(见 2026-08-18 记忆)。**
- 将 `.astro` 页面里 `getLocaleFromUrl(Astro.url)` 改为 `Astro.currentLocale`(Astro 路由解析,更稳健),并补类型兜底 `(Astro.currentLocale as Locale) ?? DEFAULT_LOCALE`。 - 将 `.astro` 页面里 `getLocaleFromUrl(Astro.url)` 改为 `Astro.currentLocale`(Astro 路由解析,更稳健),并补类型兜底 `(Astro.currentLocale as Locale) ?? DEFAULT_LOCALE`。
- 涉及文件:src/layouts/Layout.astro、src/pages/index.astro、src/pages/cn/index.astro。 - 涉及文件:src/layouts/Layout.astro、src/pages/index.astro、src/pages/cn/index.astro。
- src/i18n/utils.ts 的 `getLocaleFromUrl` 纯函数保留(供 .ts 模块或解析任意 URL 使用)。 - src/i18n/utils.ts 的 `getLocaleFromUrl` 纯函数保留(供 .ts 模块或解析任意 URL 使用)。

View File

@ -0,0 +1,49 @@
# 2026-08-18
## article 组件改为 SSR(按需服务端渲染)
- 目标:`src/components/pages/article.astro` 由「浏览器 Alpine fetch 取数(CSR)」改为「服务端渲染 + 灯箱保留 Alpine」。
- 组件改动:接收 `article: RawArticle | null` 作为 prop,服务端直出标题/品牌/日期/图片网格/正文段落(利于 SEO 与首屏,无 loading 骨架);图片地址服务端补全绝对路径;灯箱仍是 Alpine 客户端交互,但图片数据由服务端以 `x-data={articleView(${JSON.stringify(images)})}` 注入。`locale` 控制文案(en/cn),`error` 控制服务端错误态(missing/failed)。
- 页面改动:`pages/article.astro`、`pages/cn/article.astro`、`pages/en/article.astro` 三页均加 `export const prerender = false`,frontmatter 按 `Astro.url.searchParams.get('id')` 调 `fetchArticleById(id, API)` 取数后传给组件。`API = import.meta.env.API_SSR || PUBLIC_API_BASE || http://localhost:8090`。
- `src/lib/data.ts`:`fetchArticleById(id, apiBase=API_BASE)` 增加 `apiBase` 参数(复用 brands 的 API_SSR 内部地址模式);`RawArticle` 类型补 `created_at?: number`(组件引用了它)。
- 验证:`npm run build` 通过(exit 0),article 不再生成静态 html,作为 3 个按需 chunk 进 `dist/server/`;dev 下 `/en/article?id=1`、`/cn/article?id=2` 返回 200,HTML 里可见服务端渲染的错误态文案「加载文章失败,请刷新或稍后再试」与 `articleView([])` 注入(后端离线时 article=null),证明 SSR 生效、不再是浏览器 fetch。
## 重要更正:astro.config.mjs 的 prefixDefaultLocale 实际为 true
- 08-16 记忆误记为 `false`;当前 `astro.config.mjs` 实为 `prefixDefaultLocale: true`(routing.prefixDefaultLocale)。
- 因此**无前缀路由全部 404**:`/article`、`/shows`、`/articles` 都 404,只有 `/en/...`、`/cn/...` 可用。
- 这导致既存的内部链接(`shows.astro`/`articles.astro`/`Index.astro` 里的 `/article?id=`)点过去是 404——属**既存全局 i18n 路由问题**,非本次 SSR 改动引入(这些无前缀路由在我改之前就 404)。
- **部署缺口**:当前 `nginx.conf` 只做静态托管 + `/api` 反代;article 现在是按需 SSR 路由,线上需 Node 服务承接,nginx 必须把 `/en/article`、`/cn/article` 等 SSR 路由代理到 Node 服务,否则线上 404。
## 修复 swiper TS2307「找不到模块"swiper"或其相应的类型声明」
- 现象:VS Code 报 `ts(2307)` 找不到模块 `swiper`,但 `package.json` 的 `dependencies` 里已声明 `swiper: "^11.2.10"`。
- 根因:`node_modules/swiper` **根本不存在**——依赖写进了 package.json 却没真正 install 进 node_modules(疑似此前 `npm install` 未执行 / node_modules 被裁剪)。所以 TS 服务找不到模块与类型声明。
- 修复:`npm install swiper@^11.2.10`(落盘后 `node_modules/swiper` 完整,版本 11.2.10;`exports` 映射正常:`.`→swiper.mjs/swiper.d.ts,`./modules`→types/modules/index.d.ts,`./css`→swiper.css,`./css/navigation`、`/free-mode`→对应 css)。Index.astro 的 5 行 import 全部可解析。
- 提醒:若 VS Code 仍报红,执行「TypeScript: Restart TS Server」(Ctrl+Shift+P)或重开文件即可,因 TS 服务可能缓存了旧缺失状态。
## 修正:article 面包屑应复用现有 Breadcrumb 组件(非手写)
- **更正**:上一节「article 组件加面包屑」我手写了 `<nav>` 面包屑并自加 `home/archive/brand` 字典键,属于重复造轮子。用户指出项目已有 `src/components/Breadcrumb.astro`(shows 页在用)。
- **正确做法**:article.astro 改用 `<Breadcrumb items={crumbs}><span slot="current">{title}</span></Breadcrumb>`。组件约定:
- `items[].label` 是 **i18n 英文 key**(组件内部 `t()` 翻译),不要预先 `t()`;末项用 `<slot name="current">`。
- `Home` / `Runway Archive` 已在 `dictionary.ts` 有 cn 翻译(首页/走秀档案)。
- 品牌名作为动态 label 传入 `t()` 会回退原文(dict 无此 key),即显示品牌名本身。
- 语言跟随 `Astro.currentLocale`,href 需调用方自己给 locale 前缀(en→`/en/shows`,cn→`/cn/shows`,首页 en→`/en/` cn→`/cn`)。
- 撤掉手写的 `lbl.home/archive/brand`;`src/lib/data.ts` 的 `brand_id` 字段保留(品牌面包屑链接用)。
## article 组件加面包屑(Breadcrumb)
- 位置:`src/components/pages/article.astro` 顶部,把原来「返回首页 + Runway·品牌」那一行(border-b 顶栏)整体替换为标准面包屑 `<nav aria-label="Breadcrumb">`:`首页 / 走秀档案 / 品牌名 / 文章标题`。
- 链接层级(均按 locale 给前缀,避免 404):
- 首页 `homeHref`:`/en/`(en)或 `/cn`(cn)。
- 走秀档案 `archiveHref`:`/en/shows` 或 `/cn/shows`(实测两路由均 200,是可用的全局走秀档案页)。
- 品牌 `brandHref`:`archiveHref + "?brand_id=<id>"`(当 `article.brand_id` 存在,指向品牌筛选后的走秀档案);否则退化为 archiveHref。
- 当前文章标题:末级、不可点、`truncate` 防溢出。
- 文案(en/cn)新增 `home`(Home/首页)、`archive`(Runway/走秀档案)、`brand`(Brand/品牌兜底) 三个 key,沿用既有 mono 小字号大写编辑风。
- `src/lib/data.ts`:`RawArticle` 类型新增 `brand_id?: number`(后端 `/api/public/articles/:id` 返回此字段),供品牌面包屑筛选链接使用。
- 验证:`npm run build` 通过;`astro dev` 下 `/en/article?id=1` 返回 200,无运行时错误。面包屑本体在 `article ?` 分支,需后端在线有数据时才可见。
## 项目小坑(PowerShell / Node)
- `package.json` 含 `"type": "module"`,因此临时 `.js` 脚本被当 ESM,`require` 不可用;需用 `.cjs` 后缀或用 `import`。
- PowerShell 里 `||` 会被当成管道、多路径 `dir a b` 报错;做 node 自检时优先写 `.cjs` 脚本文件再 `node` 执行。
## 待用户决策(未动,留给用户定)
1. 是否修复内部 `/article?id=` 链接为 locale 前缀版(如 `/en/article?id=` / `/cn/article?id=`,按当前页 locale 生成)。需改 shows.astro / articles.astro / Index.astro 等。
2. 是否更新 nginx.conf + Dockerfile/docker-compose 让 Node SSR 服务承接 article 路由。

19
package-lock.json generated
View File

@ -537,16 +537,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz",
@ -2472,7 +2462,8 @@
"version": "3.13.11", "version": "3.13.11",
"resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.11.tgz", "resolved": "https://registry.npmjs.org/@types/alpinejs/-/alpinejs-3.13.11.tgz",
"integrity": "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA==", "integrity": "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
@ -2548,6 +2539,7 @@
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.16.1.tgz",
"integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==", "integrity": "sha512-QXcW8MK9JoG8GZ7vCt2cXJlcEPIA7Xk/7IQ1+L2iLp7sXcIxct0PrgidmHzK97gD9QlfUjHXPxujdZ1mC2PYVg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "~3.1.1" "@vue/reactivity": "~3.1.1"
} }
@ -2609,6 +2601,7 @@
"resolved": "https://registry.npmmirror.com/astro/-/astro-7.1.6.tgz", "resolved": "https://registry.npmmirror.com/astro/-/astro-7.1.6.tgz",
"integrity": "sha512-83x9rYbHazMaZkYrAFRVZXSQx2moFkz0F7cjTDUF3GWfS0a3p2vZXG1ZdhV86rStHApQCodBJW+XTD37xISIrQ==", "integrity": "sha512-83x9rYbHazMaZkYrAFRVZXSQx2moFkz0F7cjTDUF3GWfS0a3p2vZXG1ZdhV86rStHApQCodBJW+XTD37xISIrQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@astrojs/compiler-rs": "^0.3.2", "@astrojs/compiler-rs": "^0.3.2",
"@astrojs/internal-helpers": "0.10.2", "@astrojs/internal-helpers": "0.10.2",
@ -3124,6 +3117,7 @@
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@ -4613,7 +4607,7 @@
}, },
"node_modules/swiper": { "node_modules/swiper": {
"version": "11.2.10", "version": "11.2.10",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.10.tgz", "resolved": "https://registry.npmmirror.com/swiper/-/swiper-11.2.10.tgz",
"integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==", "integrity": "sha512-RMeVUUjTQH+6N3ckimK93oxz6Sn5la4aDlgPzB+rBrG/smPdCTicXyhxa+woIpopz+jewEloiEE3lKo1h9w2YQ==",
"funding": [ "funding": [
{ {
@ -4984,6 +4978,7 @@
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.0.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-8.2.0.tgz",
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",

View File

@ -3,8 +3,9 @@
import ComingSoon from "@/components/ComingSoon.astro" import ComingSoon from "@/components/ComingSoon.astro"
import { getShows, seriesLabel } from "@/lib/data" import { getShows, seriesLabel } from "@/lib/data"
import { getI18n } from "@/i18n/utils" import { getI18n } from "@/i18n/utils"
import { getRelativeLocaleUrl } from 'astro:i18n'
// SSR 预取走秀数据
const shows = await getShows(20) const shows = await getShows(20)
// 国际化:一行取当前语言 + 翻译函数 // 国际化:一行取当前语言 + 翻译函数
@ -88,7 +89,7 @@ for (const s of shows) {
{ {
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={`/article?id=${g.articleId}`} data-idx={i}> <a class="hs-item" href={getRelativeLocaleUrl(locale, `/runway-looks/${g.articleId}`)} data-idx={i}>
<!-- Alpine 管理单张图片的加载/失败状态 --> <!-- Alpine 管理单张图片的加载/失败状态 -->
<div <div
class="hs-img-box" class="hs-img-box"

View File

@ -0,0 +1,862 @@
---
import Layout from "@/layouts/Layout.astro"
import Breadcrumb from "@/components/Breadcrumb.astro"
import { getBrands, getYears, type BrandEntry } from "@/lib/api"
import { getI18n } from '@/i18n/utils'
import { getRelativeLocaleUrl } from 'astro:i18n'
const { locale, t } = getI18n(Astro)
let sidebarBrands: BrandEntry[] = []
let yearChips: number[] = []
try {
sidebarBrands = (await getBrands({ size: 300, only_with_articles: 1, featured: 1 }))
.filter((b) => b.id && b.name)
.sort((a, b) => a.name.localeCompare(b.name))
} catch {}
let yearSeed = [2026, 2025, 2024, 2023, 2022, 2021]
try {
const ys = await getYears(30)
if (ys.length) yearChips = ys
} catch {}
if (!yearChips.length) yearChips = yearSeed
---
<div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 -->
<header class="showshead">
<div>
<Breadcrumb
items={[
{ label: t('Home'), href: '/' },
{ label: 'Runway Archive', href: '/shows' },
]}
>
<span slot="current" x-text="crumbYear">All shows</span>
</Breadcrumb>
<h1 class="showsh1">All Shows</h1>
</div>
</header>
<!-- 已激活筛选 pills -->
<div class="showspills" x-show="pills.length > 0" x-cloak>
<template x-for="(p, i) in pills" :key="i">
<span class="showspill">
<span x-text="p.label"></span>
<button class="x" type="button" @click="p.clear()">×</button>
</span>
</template>
</div>
<!-- 主体:左 sidebar + 右 4 列商品 grid -->
<div class="showsbody">
<!-- LEFT SIDEBAR -->
<aside class="showsaside">
<div class="showsaside-head">
<h2 class="showsaside-h">REFINE</h2>
</div>
<div class="showsaside-body">
<!-- Designer 多选 -->
<section class="showsg">
<header class="showsg-h" @click="toggleGroup('designers')">
<span>DESIGNERS</span>
<span class="showsg-right">
<span class="showsg-count" x-text="selectedBrands.length">0</span>
<span class="showsg-chev" :class="{ 'is-collapsed': collapsedGroups.designers }">
<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>
</span>
</header>
<div class="showsg-b" x-show="!collapsedGroups.designers">
<input class="showssearch" placeholder="Search designer…" autocomplete="off" x-model.debounce.150ms="keyword" />
<ul class="showschecks">
<li>
<!-- Letter Header -->
<template x-if="item.type === 'letter'">
<div class="letter-head" x-text="item.letter"></div>
</template>
<!-- Brand Item -->
<template x-if="item.type === 'brand'">
<label class="flex items-center gap-2 w-full cursor-pointer">
<input
type="checkbox"
:value="item.id"
:checked="selectedBrands.includes(item.id)"
@change="toggleBrand(item.id)"
>
<span x-text="item.name"></span>
<span class="ct" x-text="item.count ? item.count : ''"></span>
</label>
</template>
</li>
</ul>
<a class="showsmore" @click="openBrandModal()" x-text="'浏览全部品牌 +'"></a>
</div>
</section>
<!-- Collection type -->
<section class="showsg">
<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">
<li><label><input type="checkbox" value="rtw" x-model="selectedCt" @change="refreshFilter()" /> <span>Ready-to-Wear</span></label></li>
<li><label><input type="checkbox" value="menswear" x-model="selectedCt" @change="refreshFilter()" /> <span>Menswear</span></label></li>
<li><label><input type="checkbox" value="couture" x-model="selectedCt" @change="refreshFilter()" /> <span>Couture</span></label></li>
<li><label><input type="checkbox" value="resort" x-model="selectedCt" @change="refreshFilter()" /> <span>Resort</span></label></li>
<li><label><input type="checkbox" value="pre_fall" x-model="selectedCt" @change="refreshFilter()" /> <span>Pre-Fall</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">
<li><label><input type="checkbox" value="spring" x-model="selectedSeason" @change="refreshFilter()" /> <span>Spring / Summer</span></label></li>
<li><label><input type="checkbox" value="fall" x-model="selectedSeason" @change="refreshFilter()" /> <span>Fall / Winter</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">
{yearChips.map((y) => (
<li><label><input type="checkbox" value={String(y)} x-model="selectedYear" @change="refreshFilter()" /> <span>{y}</span></label></li>
))}
</ul>
</div>
</section>
<button class="showsclear" type="button" @click="clearAll()">Clear all ×</button>
</div>
</aside>
<!-- MAIN GRID -->
<main class="showsmain">
<div class="showsgrid" :class="{ 'is-loading': loading }">
<!-- 骨架屏:结构与真实卡片完全一致,loading 期间占位,杜绝 加载中→数据完成 的尺寸抖动 -->
<template x-if="loading && items.length === 0">
<template x-for="i in skeletonCount" :key="'skel-'+i">
<div class="showscard showscard--skel" aria-hidden="true">
<div class="showscard-left">
<div class="showscard-img"></div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<div class="showsline h-[41px] mb-[10px] rounded-[2px]"></div>
<div class="showsline h-[29px] mb-[14px] rounded-[2px]"></div>
<div class="showsline h-[38px] rounded-[2px]"></div>
</div>
<div class="showscard-strip">
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
<div class="showscard-thumb"></div>
</div>
</div>
</div>
</template>
</template>
<!-- Empty State -->
<template x-if="!loading && items.length === 0">
<div class="showsempty">No shows match the current filters</div>
</template>
<!-- Card List -->
<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}`">
<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 }" />
<!-- <span class="showsbadge" x-show="isNewAdded(it.published_at)">New</span> -->
</div>
</div>
<div class="showscard-right">
<div class="showscard-info">
<h3 class="showscard-title" x-text="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 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>
</template>
</div>
<div class="showsloadmore-wrap" x-show="items.length > 0">
<button class="showsloadmore" type="button" :disabled="loadingMore || isEnd" @click="loadPage(page + 1, true)" x-text="loadBtnText"></button>
</div>
</main>
</div>
<!-- 品牌筛选弹窗:搜索 + A-Z 跳转 + 热门推荐,按需加载,避免一次拉全量 -->
<div class="showsmodal" x-show="modalOpen" x-cloak>
<div class="showsmodal-mask" @click="closeBrandModal()"></div>
<div class="showsmodal-panel">
<header class="showsmodal-head">
<h3>全部品牌</h3>
<button class="showsmodal-x" type="button" @click="closeBrandModal()" aria-label="关闭">✕</button>
</header>
<div class="showsmodal-search">
<input
class="showsmodal-input"
type="text"
x-model="modalKeyword"
@input.debounce.300ms="modalSearch()"
placeholder="搜索品牌名…"
autocomplete="off"
/>
</div>
<div class="showsmodal-az">
<template x-for="l in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ#'.split('')" :key="l">
<button class="az" type="button" :class="{ active: modalLetter === l }" @click="modalPickLetter(l)" x-text="l"></button>
</template>
</div>
<div class="showsmodal-body">
<div class="showsmodal-hint" x-show="!modalKeyword && !modalLetter">热门推荐</div>
<div class="showsmodal-loading" x-show="modalLoading" x-html="SPIN_SVG"></div>
<ul class="showsmodal-list">
<template x-for="b in modalResults" :key="b.id">
<li>
<label class="showsmodal-row">
<input
type="checkbox"
:value="b.id"
:checked="modalSelected.includes(b.id)"
:disabled="!modalSelected.includes(b.id) && modalMaxReached"
@change="toggleModalBrand(b.id)"
/>
<span class="showsmodal-name" x-text="b.name"></span>
<span class="showsmodal-ct" x-text="b.article_count ? b.article_count : ''"></span>
</label>
</li>
</template>
<li class="showsmodal-empty" x-show="!modalLoading && modalResults.length === 0">未找到匹配的品牌</li>
</ul>
</div>
<footer class="showsmodal-foot">
<span class="showsmodal-counter" x-text="`已选 ${modalSelected.length} / 最多 5 个`"></span>
<button class="showsmodal-ok" type="button" @click="confirmBrandModal()">确定</button>
</footer>
</div>
</div>
</div>
<script id="showsbrands-data" type="application/json" set:html={JSON.stringify(sidebarBrands)}></script>
<style>
@reference "tailwindcss";
[x-cloak] { display: none !important; }
/* 使用 @apply 精简并重构样式 */
.showsroot {
@apply m-auto max-w-[1600px] pt-7 text-[#0a0a0a];
}
.showshead {
@apply flex justify-between items-end gap-6 py-5 pb-[26px] ;
}
.showsh1 {
@apply text-[clamp(40px,6vw,72px)] leading-[0.9] font-black tracking-[-0.02em] my-0 mb-[10px];
}
.showspills {
@apply flex flex-wrap gap-2 pt-3.5;
}
.showspill {
@apply inline-flex items-center gap-2 border-[1.5px] border-black p-[6px_10px] text-[10.5px] uppercase tracking-[0.14em] text-black bg-white;
}
.showspill .x {
@apply inline-flex w-3.5 h-3.5 items-center justify-center border-none bg-black text-white cursor-pointer text-xs leading-none;
}
.showsbody {
@apply flex gap-[36px] items-start max-md:flex-col max-md:gap-[18px];
}
.showsaside {
@apply flex-none w-[240px] bg-white sticky top-5 max-h-[calc(100vh-40px)] rounded-none transition-[flex-basis] duration-250 ease-in-out flex flex-col max-md:static max-md:max-h-none max-md:w-full;
}
.showsaside-head {
@apply flex items-center justify-between p-[14px_14px_12px] border-b border-black shrink-0;
}
.showsaside-h {
@apply text-[11px] tracking-[0.26em] uppercase font-bold m-0 leading-none transition-opacity duration-200;
}
.showsaside-body {
@apply p-[14px_14px_18px] overflow-y-auto flex-1 min-h-0 [scrollbar-width:thin] [scrollbar-color:#ccc_#fff];
}
.showsg {
@apply border-t border-[#e6e6e6] py-[12px] pb-[6px] first-of-type:border-t-0 first-of-type:pt-1;
}
.showsg-h {
@apply flex justify-between items-center text-[11px] tracking-[0.22em] uppercase font-bold text-black mb-2 cursor-pointer select-none;
}
.showsg-right {
@apply inline-flex items-center gap-2.5;
}
.showsg-chev {
@apply inline-block text-[9px] leading-none text-[#999] transition-transform duration-200 ml-1;
}
.showsg-chev.is-collapsed {
@apply -rotate-90;
}
.showsg-count {
@apply text-[#999] font-normal text-[10px] tracking-[0.14em];
}
.showssearch {
@apply w-full border border-[#d4d4d4] p-[7px_10px] text-[11px] uppercase tracking-[0.08em] text-black rounded-none outline-none bg-[#fafafa] focus:border-black focus:bg-white;
}
.showschecks {
@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];
}
.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="checkbox"]:checked::after {
content: "";
@apply w-[6px] h-[6px] bg-white;
}
.showschecks .ct {
@apply text-[#999] text-[10px] ml-auto;
}
.showschecks--flat li {
@apply py-[4px];
}
.showschecks--flat li label {
@apply inline-flex items-center gap-2 cursor-pointer w-full;
}
.showsmore {
@apply inline-block mt-[10px] text-[10.5px] uppercase tracking-[0.16em] text-[#555] cursor-pointer hover:text-black;
}
.showsclear {
@apply mt-[18px] w-full border-[1.5px] border-black bg-white text-black p-[11px_12px] text-[11px] uppercase tracking-[0.22em] font-bold cursor-pointer rounded-none transition-all duration-150 hover:bg-black hover:text-white;
}
.showsmain {
@apply flex-1 min-w-0;
}
.showsgrid {
@apply grid grid-cols-2 gap-[22px] relative max-[560px]:grid-cols-1;
}
.showsgrid.is-loading::before {
content: "";
@apply absolute -top-1 left-0 h-[2px] w-[38%] bg-black z-10;
animation: hbxProg 1s ease-in-out infinite;
}
.showscard {
@apply relative flex gap-[14px] no-underline text-inherit bg-white p-3 transition-colors duration-200 hover:border-black max-[700px]:flex-col max-[700px]:gap-[10px];
}
.showscard:hover .showscard-img img {
@apply scale-104;
}
.showscard-left {
@apply flex-none w-[30%] min-w-[110px] max-[700px]:w-full max-[700px]:flex-none;
}
.showscard-img {
@apply relative w-full aspect-[3/4] bg-[#f1f1f1] overflow-hidden rounded-none;
}
.showscard-img img {
@apply w-full h-full object-cover transition-transform duration-600 opacity-0;
}
.showscard-img img.is-loaded {
@apply opacity-100;
}
.showsspin {
@apply absolute inset-0 flex items-center justify-center text-[#bdbdbd] opacity-100 transition-opacity duration-300 pointer-events-none z-10;
}
.showscard-img.is-loaded .showsspin,
.showscard-thumb.is-loaded .showsspin {
@apply opacity-0;
}
.showsbadge {
@apply absolute top-[6px] right-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-white bg-black p-[3px_6px];
}
.showsflag-featured {
@apply absolute top-[6px] left-[6px] text-[9px] tracking-[0.14em] uppercase font-bold text-black bg-white p-[3px_6px] border border-black;
}
.showscard-right {
@apply flex-1 min-w-0 flex flex-col justify-between max-[700px]:w-full;
}
.showscard-info {
@apply flex-none min-w-0 flex flex-col p-[2px_2px_0];
}
.showscard-title {
@apply text-[15.5px] font-bold leading-[1.32] my-0 mb-[10px] text-[#111] tracking-[-0.01em] line-clamp-2 h-[41px] overflow-hidden;
}
.showscard-meta {
@apply text-[9.5px] text-[#888] my-0 mb-[14px] leading-[1.55] tracking-[0.04em] uppercase line-clamp-2 h-[29px] overflow-hidden;
}
.showscard-meta-foot {
@apply flex flex-col gap-[6px] h-[38px] overflow-hidden;
}
.showscard-meta-line {
@apply inline-flex items-center gap-[6px] text-[10.5px] tracking-[0.06em] uppercase text-[#444] h-[16px] leading-[16px] overflow-hidden whitespace-nowrap;
}
.showscard-strip {
@apply grid grid-cols-5 gap-1 mt-[10px];
}
.showscard-thumb {
@apply relative aspect-[3/4] bg-[#f1f1f1] overflow-hidden;
}
.showscard-thumb img {
@apply w-full h-full object-cover opacity-0 transition-opacity duration-450 ease-in-out;
}
.showscard-thumb img.is-loaded {
@apply opacity-100;
}
.showscard-thumb.placeholder {
@apply bg-[#f6f6f6];
}
.showscard-thumb-more {
@apply absolute inset-0 bg-black/55 text-white text-[11px] font-bold tracking-[0.04em] flex items-center justify-center;
}
.showscard--skel .showscard-img,
.showscard--skel .showscard-thumb,
.showscard--skel .showsline {
@apply bg-[#efefef];
animation: hbxShim 1.6s ease infinite;
}
.showsloadmore-wrap {
@apply flex justify-center 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;
}
.showsempty {
@apply col-span-full p-[80px_20px] text-center text-xs tracking-[0.22em] uppercase text-[#999] border border-dashed border-[#ccc];
}
@keyframes hbxProg { 0% { left: -38%; } 100% { left: 100%; } }
@keyframes hbxShim { 0%, 100% { opacity: .85; } 50% { opacity: .55; } }
/* ===== 品牌筛选弹窗 ===== */
.showsmodal {
@apply fixed inset-0 z-[60] flex items-center justify-center p-[16px];
}
.showsmodal-mask {
@apply absolute inset-0 bg-black/45;
}
.showsmodal-panel {
@apply relative w-full max-w-[680px] max-h-[86vh] bg-white flex flex-col border border-black;
}
.showsmodal-head {
@apply flex items-center justify-between px-[16px] py-[13px] border-b border-black shrink-0;
}
.showsmodal-head h3 {
@apply m-0 text-[13px] tracking-[0.2em] uppercase font-bold;
}
.showsmodal-x {
@apply appearance-none bg-white border border-black w-[24px] h-[24px] inline-flex items-center justify-center cursor-pointer text-[12px] leading-none;
}
.showsmodal-x:hover { @apply bg-black text-white; }
.showsmodal-search { @apply px-[16px] pt-[12px] pb-[8px] shrink-0; }
.showsmodal-input {
@apply w-full border border-black px-[10px] py-[8px] text-[13px] outline-none;
}
.showsmodal-input:focus { @apply ring-1 ring-black; }
.showsmodal-az {
@apply flex flex-wrap gap-[4px] px-[16px] pb-[10px] border-b border-[#e6e6e6] shrink-0;
}
.showsmodal-az .az {
@apply appearance-none bg-white border border-[#ccc] w-[22px] h-[22px] text-[11px] cursor-pointer leading-none;
}
.showsmodal-az .az.active { @apply bg-black text-white border-black; }
.showsmodal-body { @apply flex-1 min-h-0 overflow-y-auto px-[16px] py-[8px]; }
.showsmodal-hint {
@apply text-[11px] tracking-[0.2em] uppercase text-[#888] py-[6px] pb-[8px];
}
.showsmodal-loading { @apply flex justify-center py-[28px] text-[#bdbdbd]; }
.showsmodal-list { @apply list-none m-0 p-0; }
.showsmodal-row {
@apply flex items-center gap-[8px] w-full cursor-pointer py-[7px] border-b border-[#f0f0f0] text-[13px];
}
.showsmodal-name { @apply flex-1 truncate; }
.showsmodal-ct { @apply text-[#999] text-[11px] ml-auto; }
.showsmodal-empty { @apply py-[28px] text-center text-[#999] text-[13px]; }
.showsmodal-foot {
@apply flex items-center justify-between px-[16px] py-[12px] border-t border-black shrink-0 text-[12px];
}
.showsmodal-ok {
@apply appearance-none bg-black text-white border border-black px-[22px] py-[8px] cursor-pointer text-[12px] tracking-[0.1em];
}
</style>
<script>
import Alpine from "alpinejs"
import { getArticles, getBrands, toAbs, type ArticleItem, type BrandEntry } from "@/lib/api"
const ICON_USER = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><circle cx="6" cy="4" r="2" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 10c0-2 1.8-3 4-3s4 1 4 3" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const ICON_DATE = `<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true"><rect x="2" y="3" width="8" height="7" stroke="currentColor" fill="none" stroke-width="1"/><path d="M2 6h8M4 2v2M8 2v2" stroke="currentColor" fill="none" stroke-width="1" stroke-linecap="round"/></svg>`
const SPIN_SVG = `<svg viewBox="0 0 24 24" aria-hidden="true" class="w-[22px] h-[22px] block"><g class="ring origin-[12px_12px] animate-spin" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="40 22"><circle cx="12" cy="12" r="9"></circle></g><g opacity="0.55" fill="none" stroke="currentColor" stroke-width="1"><rect x="8" y="9.6" width="8" height="5.4"></rect><circle cx="12" cy="12.3" r="1.6"></circle></g></svg>`
document.addEventListener("alpine:init", () => {
Alpine.data("showsPage", () => {
const node = document.getElementById("showsbrands-data")
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
return {
locale: '',
allBrands,
ICON_USER,
ICON_DATE,
SPIN_SVG,
collapsedGroups: {
designers: localStorage.getItem("showsg-designers") === "1",
collection: localStorage.getItem("showsg-collection") === "1",
season: localStorage.getItem("showsg-season") === "1",
year: localStorage.getItem("showsg-year") === "1",
},
selectedBrands: [] as number[],
selectedCt: [] as string[],
selectedSeason: [] as string[],
selectedYear: [] as string[],
keyword: "",
brandsExpanded: false,
// 品牌筛选弹窗状态
modalOpen: false,
modalKeyword: "",
modalLetter: "",
modalResults: [] as BrandEntry[],
modalLoading: false,
modalSelected: [] as number[],
get modalMaxReached() {
return this.modalSelected.length >= 5
},
items: [] as (ArticleItem & { _imgLoaded?: boolean })[],
loading: false,
loadingMore: false,
page: 1,
size: 24,
total: 0,
isEnd: false,
reqToken: 0,
init() {
this.locale = this.$el.dataset.locale || 'zh-cn';
this.loadPage(1, false)
},
// 计算属性:侧边栏设计师列表的分组渲染
get visibleBrandItems() {
const kw = this.keyword.trim().toLowerCase()
const result: any[] = []
if (kw) {
this.allBrands.forEach((b) => {
if (b.name.toLowerCase().includes(kw)) {
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
}
})
} else {
const map = new Map<string, BrandEntry[]>()
for (const b of this.allBrands) {
const letter = (b.name[0] || "?").toUpperCase()
if (!map.has(letter)) map.set(letter, [])
map.get(letter)!.push(b)
}
const grouped = [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
let shown = 0
const MAX_INITIAL = 60
for (const [letter, list] of grouped) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "letter", letter, key: `letter-${letter}` })
for (const b of list) {
if (!this.brandsExpanded && shown >= MAX_INITIAL) break
result.push({ type: "brand", id: b.id, name: b.name, count: b.article_count, key: b.id })
shown++
}
}
}
return result
},
get hasMoreBrands() {
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 数量一致,保证「加载中」与「数据完成」两阶段的网格高度相同,消除抖动
get skeletonCount() {
return this.size
},
// 已筛选选项 Pills 视图数据
get pills() {
const list: { label: string; clear: () => void }[] = []
if (this.selectedBrands.length) {
const labels = this.selectedBrands
.map((id) => this.allBrands.find((b) => b.id === id)?.name ?? `#${id}`)
.slice(0, 4)
const more = this.selectedBrands.length > 4 ? ` +${this.selectedBrands.length - 4}` : ""
list.push({
label: "DESIGNER · " + labels.join(", ") + more,
clear: () => { this.selectedBrands = []; this.refreshFilter(); }
})
}
if (this.selectedCt.length) {
list.push({
label: "COLLECTION · " + this.selectedCt.join("+").toUpperCase(),
clear: () => { this.selectedCt = []; this.refreshFilter(); }
})
}
if (this.selectedSeason.length) {
list.push({
label: "SEASON · " + this.selectedSeason.join("+").toUpperCase(),
clear: () => { this.selectedSeason = []; this.refreshFilter(); }
})
}
if (this.selectedYear.length) {
list.push({
label: "YEAR · " + [...this.selectedYear].sort().join("+"),
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, false)
},
// ===== 品牌筛选弹窗方法 =====
async openBrandModal() {
this.modalSelected = [...this.selectedBrands]
this.modalKeyword = ""
this.modalLetter = ""
this.modalOpen = true
await this.modalLoadHot()
},
closeBrandModal() {
this.modalOpen = false
},
async modalLoadHot() {
this.modalLoading = true
try {
const list = await getBrands({ featured: 1, size: 50 })
this.modalResults = list.length ? list : this.allBrands.slice(0, 50)
} catch {
this.modalResults = this.allBrands.slice(0, 50)
} finally {
this.modalLoading = false
}
},
async modalSearch() {
const kw = this.modalKeyword.trim()
if (kw) {
this.modalLetter = ""
this.modalLoading = true
try {
this.modalResults = await getBrands({ keyword: kw, size: 60 })
} catch {
this.modalResults = []
} finally {
this.modalLoading = false
}
} else if (this.modalLetter) {
await this.modalPickLetter(this.modalLetter)
} else {
await this.modalLoadHot()
}
},
async modalPickLetter(l: string) {
this.modalLetter = l
this.modalKeyword = ""
this.modalLoading = true
try {
const letter = l === "#" ? "OTHER" : l
this.modalResults = await getBrands({ letter, size: 200 })
} 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.modalOpen = false
this.refreshFilter()
},
clearAll() {
this.selectedBrands = []
this.selectedCt = []
this.selectedSeason = []
this.selectedYear = []
this.keyword = ""
this.refreshFilter()
},
// API 加载
async loadPage(page: number, append = false) {
const myToken = ++this.reqToken
if (append) {
this.loadingMore = true
} else {
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,
})
if (myToken !== this.reqToken) return
const rows = json.data.map((item: ArticleItem) => ({ ...item, _imgLoaded: false }))
this.total = json.total
this.page = page
this.items = append ? [...this.items, ...rows] : rows
this.isEnd = page >= json.last_page || rows.length < this.size
} catch {
if (myToken !== this.reqToken) return
if (!append) this.items = []
} finally {
if (myToken === this.reqToken) {
this.loading = false
this.loadingMore = false
}
}
},
// 辅助辅助工具函数
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) {
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 || "")
},
getCardSlots(it: ArticleItem) {
const gallery = it.gallery || []
const totalCount = it.image_count || 0
const moreLabel = totalCount > 30 ? "30+" : `${totalCount || 0}+`
const slots = []
for (let i = 0; i < 5; i++) {
const src = gallery[i]
const isLast = i === 4
slots.push({ src, loaded: false, isLast, moreLabel: (isLast && totalCount > 0) ? moreLabel : "" })
}
return slots
}
}
})
})
</script>

View File

@ -1,67 +1,115 @@
<!-- Alpine.js 防止闪烁样式 --> ---
<style> import type { RawArticle } from "@/lib/data"
[x-cloak] { display: none !important; } import Breadcrumb from "@/components/Breadcrumb.astro"
/* 隐藏缩略图侧边栏滚动条 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>
<div x-data="articleDetail()" class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white"> interface Props {
article: RawArticle | null
locale?: "en" | "cn"
apiBase?: string
error?: "missing" | "failed" | null
}
const { article, locale = "en", apiBase = "", error = null } = Astro.props
<!-- 1. 加载中状态 (Loading) --> // 把相对图片地址补全为绝对地址(服务端完成,避免浏览器再拼)
<div x-show="loading" class="flex flex-col items-center justify-center min-h-[60vh] text-[12px] font-mono uppercase tracking-[0.2em] text-[#888]"> const full = (u: string): string =>
<div class="w-7 h-7 border-2 border-black border-t-transparent rounded-full animate-spin mb-4"></div> !u ? "" : /^https?:\/\//i.test(u) ? u : apiBase.replace(/\/$/, "") + u
<span>Loading Article...</span>
</div>
<!-- 2. 错误提示状态 (Error) --> // 图片数组:服务端预处理为灯箱可用的 {id,url,name}
<div x-show="error && !loading" x-cloak class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center"> const images = (article?.images ?? []).map((im) => ({
<p class="font-mono text-sm text-red-500 uppercase tracking-widest mb-4" x-text="error"></p> id: im.id,
<a href="/" class="text-[12px] font-mono uppercase tracking-widest border border-black px-4 py-2 hover:bg-black hover:text-white transition-colors"> url: full(im.image || ""),
← Back to Index name: im.name || "",
</a> }))
</div>
<!-- 3. 文章主内容 --> // 正文段落(如有)
<article x-show="!loading && article" x-cloak class="mx-auto max-w-[1100px] px-5 md:px-10 py-12 md:py-16"> const descParas = (article?.description || "")
<!-- 面包屑 / 品牌 --> .split(/\n\n+/)
<div class="flex items-center justify-between text-[11px] font-mono uppercase tracking-[0.25em] text-black border-b border-black pb-4"> .map((s) => s.trim())
<a href="/" class="hover:opacity-60 transition-opacity">← Index</a> .filter(Boolean)
<span>Runway · <span x-text="article?.brand_name || 'Brand'"></span></span>
</div>
<!-- 标题 --> // 文案(按语言切换)
<h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8" x-text="article?.title"></h1> const L = {
en: {
back: "← Back to Index",
source: "Source ↗",
runway: "Runway",
notFound: "Article not found",
missing: "No article ID specified",
failed: "Failed to load article. Please refresh or try later.",
},
cn: {
back: "← 返回首页",
source: "来源 ↗",
runway: "秀场",
notFound: "未找到该文章",
missing: "未指定文章 ID",
failed: "加载文章失败,请刷新或稍后再试",
},
}
const lbl = L[locale === "cn" ? "cn" : "en"]
const backLabel = lbl.back.replace("← ", "")
// 面包屑:首页 → 走秀档案(/shows) → 品牌筛选(带 brand_id) → 当前文章(复用项目 Breadcrumb 组件)
const homeHref = locale === "cn" ? "/cn" : "/en/"
const archiveHref = locale === "cn" ? "/cn/shows" : "/en/shows"
const crumbs: { label: string; href: string }[] = [
{ label: "Home", href: homeHref },
{ label: "Runway Archive", href: archiveHref },
]
if (article?.brand_id) {
crumbs.push({
label: article.brand_name || "Brand",
href: `${archiveHref}?brand_id=${article.brand_id}`,
})
}
---
<!-- 元信息 --> <!-- 外层 x-data 承载灯箱状态;图片数据由服务端注入,无需浏览器再 fetch -->
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-[12px] font-mono uppercase tracking-widest text-[#666]"> <div x-data={`articleView(${JSON.stringify(images)})`} class="min-h-screen bg-white text-black font-sans selection:bg-black selection:text-white">
<span x-text="formatDate(article?.published_at || article?.created_at)"></span>
<span class="text-black" x-text="article?.year"></span>
<template x-if="article?.source_url">
<a :href="article.source_url" target="_blank" rel="noopener" class="hover:text-black underline underline-offset-4">Source ↗</a>
</template>
</div>
<!-- 图片集 --> {article ? (
<section class="mt-16" x-show="article?.images && article.images.length > 0"> <article class="mx-auto max-w-[1600px] px-5 md:px-10 py-12 md:py-16">
<!-- 面包屑(复用项目 Breadcrumb 组件):首页 / 走秀档案 / 品牌 / 当前文章 -->
<Breadcrumb items={crumbs}>
<span slot="current" class="max-w-[60vw] truncate">{article.title}</span>
</Breadcrumb>
<!-- 图片网格 -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4"> <!-- 标题 -->
<template x-for="(im, i) in article?.images" :key="im.id || i"> <h1 class="font-black leading-[0.95] tracking-tight text-[10vw] md:text-[4.5rem] mt-8">{article.title}</h1>
<figure @click="openLb(i)" class="bg-[#f2f2f2] cursor-zoom-in group">
<img :src="im.imageUrl" :alt="im.name" loading="lazy" class="w-full aspect-[3/4] object-cover group-hover:opacity-90 transition-opacity" /> <!-- 图片集(服务端渲染,利于 SEO 与首屏) -->
<figcaption class="text-[11px] font-mono uppercase tracking-widest text-[#999] mt-2" x-text="im.name || ''"></figcaption> {images.length > 0 && (
</figure> <section class="mt-16">
</template> <div class="grid grid-cols-2 md:grid-cols-3 gap-4">
{images.map((im, i) => (
<figure @click={`openLb(${i})`} class="bg-[#f2f2f2] cursor-zoom-in group">
<img src={im.url} alt={im.name} loading="lazy" 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>
)}
<div class="mt-16 pt-6 border-t border-black">
<a href={homeHref} class="text-[12px] font-mono uppercase tracking-widest hover:opacity-60 transition-opacity">{lbl.back}</a>
</div> </div>
</section> </article>
) : (
<div class="mt-16 pt-6 border-t border-black"> <div class="flex flex-col items-center justify-center min-h-[60vh] px-5 text-center">
<a href="/" class="text-[12px] font-mono uppercase tracking-widest hover:opacity-60 transition-opacity">← Back to Index</a> <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> </div>
</article> )}
<!-- 4. 全屏灯箱组件 (Lightbox) --> <!-- 4. 全屏灯箱组件 (Lightbox,Alpine 客户端交互) -->
<div <div
x-show="lbOpen" x-show="lbOpen"
x-cloak x-cloak
@ -71,7 +119,7 @@
class="fixed inset-0 z-[100] bg-white flex flex-col select-none" class="fixed inset-0 z-[100] bg-white flex flex-col select-none"
> >
<!-- 计数器 --> <!-- 计数器 -->
<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} / ${article?.images?.length || 0}`"></div> <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} / ${images.length || 0}`"></div>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<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="关闭"> <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="关闭">
@ -82,14 +130,14 @@
<div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative"> <div class="flex-1 min-h-0 flex items-stretch overflow-hidden relative">
<!-- 左侧缩略图纵向列表 --> <!-- 左侧缩略图纵向列表 -->
<div x-ref="lbThumbs" 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"> <div x-ref="lbThumbs" 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">
<template x-for="(im, i) in article?.images" :key="im.id || i"> <template x-for="(im, i) in images" :key="im.id || i">
<button <button
type="button" type="button"
@click="showLb(i)" @click="showLb(i)"
class="relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200" class="relative block w-full aspect-[3/4] mb-2 rounded-[2px] overflow-hidden cursor-pointer transition-all duration-200"
:class="lbIndex === i ? 'opacity-100 scale-[1.03] ring-2 ring-[#111]' : 'opacity-40 hover:opacity-85'" :class="lbIndex === i ? 'opacity-100 scale-[1.03] ring-2 ring-[#111]' : 'opacity-40 hover:opacity-85'"
> >
<img :src="im.imageUrl" alt="" class="w-full h-full object-cover block" /> <img :src="im.url" alt="" class="w-full h-full object-cover block" />
<span <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" 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"
:class="lbIndex === i ? '!bg-[#111]' : ''" :class="lbIndex === i ? '!bg-[#111]' : ''"
@ -124,10 +172,10 @@
class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18" class="flex-1 min-w-0 min-h-0 relative flex justify-center px-4 sm:px-18"
:class="lbZoomed ? 'block overflow-y-auto overflow-x-hidden items-start' : 'items-center'" :class="lbZoomed ? 'block overflow-y-auto overflow-x-hidden items-start' : 'items-center'"
> >
<template x-if="article?.images && article.images[lbIndex]"> <template x-if="images && images[lbIndex]">
<img <img
:src="article.images[lbIndex].imageUrl" :src="images[lbIndex].url"
:alt="article.images[lbIndex].name || ''" :alt="images[lbIndex].name || ''"
@click="toggleZoom()" @click="toggleZoom()"
class="block select-none transition-opacity duration-200" class="block select-none transition-opacity duration-200"
: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'" :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'"
@ -137,7 +185,7 @@
</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="article?.images[lbIndex]?.name || ''"></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="images[lbIndex]?.name || ''"></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"> <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">
@ -147,16 +195,11 @@
</div> </div>
<!-- Alpine.js 业务逻辑代码 --> <!-- Alpine.js 业务逻辑:仅负责灯箱交互,图片数据由服务端注入 -->
<script> <script>
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
Alpine.data('articleDetail', () => ({ Alpine.data('articleView', (images = []) => ({
loading: true, images,
error: null,
article: null,
bodyParagraphs: [],
// 灯箱状态
lbOpen: false, lbOpen: false,
lbIndex: 0, lbIndex: 0,
lbZoomed: false, lbZoomed: false,
@ -165,126 +208,81 @@
touchStartX: 0, touchStartX: 0,
touchStartY: 0, touchStartY: 0,
async init() {
const api = import.meta.env.PUBLIC_API_BASE || "";
const id = new URLSearchParams(window.location.search).get("id");
if (!id) {
this.error = "未指定文章 ID (Missing Article ID)";
this.loading = false;
return;
}
try {
const res = await fetch(`${api}/api/public/articles/${id}`);
if (!res.ok) throw new Error("网络请求失败");
const resData = await res.json();
if (!resData || !resData.data) throw new Error("未获取到有效文章数据");
const data = resData.data;
// 补全图片相对路径
if (Array.isArray(data.images)) {
data.images = data.images.map(im => ({
...im,
imageUrl: im.image ? (/^https?:\/\//i.test(im.image) ? im.image : api + im.image) : ""
}));
}
this.article = data;
// 处理正文段落
if (data.description) {
this.bodyParagraphs = data.description.split("\n\n").filter(Boolean);
}
} catch (err) {
console.error("Fetch Article Error:", err);
this.error = "加载文章失败,请刷新或稍后再试";
} finally {
this.loading = false;
}
},
// 时间格式化辅助函数
formatDate(ts) {
if (!ts) return "";
const dt = new Date(Number(ts) * 1000);
if (isNaN(dt.getTime())) return "";
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const day = String(dt.getDate()).padStart(2, "0");
return `${y}.${m}.${day}`;
},
// 打开灯箱 // 打开灯箱
openLb(index = 0) { openLb(index = 0) {
if (!this.article?.images?.length) return; if (!this.images?.length) return
this.lbIndex = index; this.lbIndex = index
this.lbZoomed = false; this.lbZoomed = false
this.lbOpen = true; this.lbOpen = true
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden"
this.scrollThumbIntoView(); this.scrollThumbIntoView()
}, },
// 关闭灯箱 // 关闭灯箱
closeLb() { closeLb() {
this.lbOpen = false; this.lbOpen = false
this.lbZoomed = false; this.lbZoomed = false
document.body.style.overflow = ""; document.body.style.overflow = ""
}, },
// 切换视图 // 切换视图
showLb(index) { showLb(index) {
const total = this.article?.images?.length || 0; const total = this.images?.length || 0
if (total === 0) return; if (total === 0) return
this.lbIndex = (index + total) % total; this.lbIndex = (index + total) % total
this.resetZoom(); this.resetZoom()
this.scrollThumbIntoView(); this.scrollThumbIntoView()
}, },
nextLb() { this.showLb(this.lbIndex + 1); }, nextLb() { this.showLb(this.lbIndex + 1) },
prevLb() { this.showLb(this.lbIndex - 1); }, prevLb() { this.showLb(this.lbIndex - 1) },
toggleZoom() { toggleZoom() {
this.lbZoomed = !this.lbZoomed; this.lbZoomed = !this.lbZoomed
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0; if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
}, },
resetZoom() { resetZoom() {
this.lbZoomed = false; this.lbZoomed = false
if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0; if (this.$refs.lbMain) this.$refs.lbMain.scrollTop = 0
}, },
// 左侧缩略图自动居中滚到视野中央 // 左侧缩略图自动居中滚到视野中央
scrollThumbIntoView() { scrollThumbIntoView() {
this.$nextTick(() => { this.$nextTick(() => {
const thumbsEl = this.$refs.lbThumbs; const thumbsEl = this.$refs.lbThumbs
if (!thumbsEl) return; if (!thumbsEl) return
const activeThumb = thumbsEl.children[this.lbIndex]; const activeThumb = thumbsEl.children[this.lbIndex]
if (activeThumb) { if (activeThumb) {
const target = activeThumb.offsetTop - (thumbsEl.clientHeight - activeThumb.clientHeight) / 2; const target = activeThumb.offsetTop - (thumbsEl.clientHeight - activeThumb.clientHeight) / 2
thumbsEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); thumbsEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" })
} }
}); })
}, },
// 手势处理 // 手势处理
handleTouchStart(e) { handleTouchStart(e) {
if (e.touches.length === 1) { if (e.touches.length === 1) {
this.touchStartX = e.touches[0].clientX; this.touchStartX = e.touches[0].clientX
this.touchStartY = e.touches[0].clientY; this.touchStartY = e.touches[0].clientY
} }
}, },
handleTouchEnd(e) { handleTouchEnd(e) {
if (this.lbZoomed) return; if (this.lbZoomed) return
const t = e.changedTouches[0]; const t = e.changedTouches[0]
const dx = t.clientX - this.touchStartX; const dx = t.clientX - this.touchStartX
const dy = t.clientY - this.touchStartY; const dy = t.clientY - this.touchStartY
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) { if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) {
dx < 0 ? this.nextLb() : this.prevLb(); dx < 0 ? this.nextLb() : this.prevLb()
} }
} }
})); }))
}); });
</script> </script>
<style>
/* 隐藏缩略图侧边栏滚动条 */
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@ -17,9 +17,11 @@ export type RawArticle = {
description?: string description?: string
cover: string cover: string
brand_name: string brand_name: string
brand_id?: number
year: number year: number
image_count: number image_count: number
published_at: number published_at: number
created_at?: number
source_url?: string source_url?: string
collection_type?: string collection_type?: string
season?: string season?: string
@ -113,10 +115,11 @@ export function loadArticles(): Promise<Loaded> {
return _loaded return _loaded
} }
export async function fetchArticleById(id: number | string): Promise<RawArticle | null> { export async function fetchArticleById(id: number | string, apiBase: string = API_BASE): Promise<RawArticle | null> {
if (!API_BASE) return null const base = apiBase.replace(/\/$/, "")
if (!base) return null
try { try {
const res = await fetch(`${API_BASE}/api/public/articles/${id}`) const res = await fetch(`${base}/api/public/articles/${id}`)
if (!res.ok) return null if (!res.ok) return null
const json: any = await res.json() const json: any = await res.json()
return json?.data ?? null return json?.data ?? null

View File

@ -1,8 +1,27 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro" import Article from "@/components/pages/article.astro"
import { fetchArticleById } from "@/lib/data"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /article?id= 链接,无需改动其他页面)
export const prerender = false
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
const id = Astro.url.searchParams.get("id")
let article: import("@/lib/data").RawArticle | null = null
let error: "missing" | "failed" | null = null
if (!id) {
error = "missing"
} else {
try {
article = await fetchArticleById(id, API)
} catch {
article = null
}
error = article ? null : "failed"
}
--- ---
<Layout> <Layout>
<Article /> <Article article={article} locale="en" apiBase={API} error={error} />
</Layout> </Layout>

View File

@ -1,8 +1,27 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro" import Article from "@/components/pages/article.astro"
import { fetchArticleById } from "@/lib/data"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /cn/article?id= 链接,无需改动其他页面)
export const prerender = false
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
const id = Astro.url.searchParams.get("id")
let article: import("@/lib/data").RawArticle | null = null
let error: "missing" | "failed" | null = null
if (!id) {
error = "missing"
} else {
try {
article = await fetchArticleById(id, API)
} catch {
article = null
}
error = article ? null : "failed"
}
--- ---
<Layout> <Layout>
<Article /> <Article article={article} locale="cn" apiBase={API} error={error} />
</Layout> </Layout>

View File

@ -1,8 +1,27 @@
--- ---
import Layout from "@/layouts/Layout.astro" import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro" import Article from "@/components/pages/article.astro"
import { fetchArticleById } from "@/lib/data"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
export const prerender = false
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
const id = Astro.url.searchParams.get("id")
let article: import("@/lib/data").RawArticle | null = null
let error: "missing" | "failed" | null = null
if (!id) {
error = "missing"
} else {
try {
article = await fetchArticleById(id, API)
} catch {
article = null
}
error = article ? null : "failed"
}
--- ---
<Layout> <Layout>
<Article /> <Article article={article} locale="en" apiBase={API} error={error} />
</Layout> </Layout>

View File

@ -1,5 +1,27 @@
--- ---
export const prerender = false; import Layout from "@/layouts/Layout.astro"
import Article from "@/components/pages/article.astro"
import { fetchArticleById } from "@/lib/data"
// 按需 SSR:服务端按 ?id= 取数并渲染(保留既有 /en/article?id= 链接,无需改动其他页面)
export const prerender = false
const API = (import.meta.env.API_SSR || import.meta.env.PUBLIC_API_BASE || "http://localhost:8090").replace(/\/$/, "")
const {id} = Astro.params
let article: import("@/lib/data").RawArticle | null = null
let error: "missing" | "failed" | null = null
if (!id) {
error = "missing"
} else {
try {
article = await fetchArticleById(id, API)
} catch {
article = null
}
error = article ? null : "failed"
}
--- ---
123123123 <Layout>
<Article article={article} locale="en" apiBase={API} error={error} />
</Layout>

View File

@ -3,5 +3,5 @@ import Layout from '@/layouts/Layout.astro'
import RunwayLooks from '@/components/RunwayLooks.astro' import RunwayLooks from '@/components/RunwayLooks.astro'
--- ---
<Layout> <Layout>
<RunwayLooks />
</Layout> </Layout>

View File

@ -24,7 +24,7 @@ if (!yearChips.length) yearChips = yearSeed
--- ---
<Layout title="All Shows — Portfolio Studio"> <Layout title="All Shows — Portfolio Studio">
<div class="showsroot w-full" x-data="showsPage()"> <div class="showsroot w-full" x-data="showsPage()" data-locale={locale}>
<!-- 顶部:极简眉头 --> <!-- 顶部:极简眉头 -->
<header class="showshead"> <header class="showshead">
<div> <div>
@ -190,7 +190,7 @@ 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="`/article?id=${it.id}`" :aria-label="`${it.brand_name} ${it.title}`"> <a class="showscard" :href="`/${localeUrl}?id=${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>
@ -559,6 +559,7 @@ if (!yearChips.length) yearChips = yearSeed
const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : [] const allBrands: BrandEntry[] = node?.textContent ? JSON.parse(node.textContent) : []
return { return {
localeUrl: this.dataset.locale,
allBrands, allBrands,
ICON_USER, ICON_USER,
ICON_DATE, ICON_DATE,