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

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

View File

@ -0,0 +1,43 @@
# 2026-08-30 工作日志
## 首页 Street Style 图片过大调整(11:33)
- 用户反馈首页 Street Style 区块图片太大。定位到 `src/components/Index.astro` 的 `<style>`:
- `.ss-banner` 原 `aspect-ratio:16/9` + `w-full` → 桌面端整宽约 675px 高(巨型 hero);
- `.ss-grid` 原仅 `sm:grid-cols-2` + `.ss-card` `aspect-ratio:3/4` → 桌面两列每张卡约 1000px 高,竖排 11 张占空间极大。
- 调整(保守,不改图片比例、保持编辑风):
- `.ss-banner` 加 `max-height: 440px;`(仅桌面端收窄 hero)。
- `.ss-grid` 改为 `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`(中等屏起加第 3 列,单图立刻变小)。
- 验证:`npm run build` BUILD_EXIT=0;read_lints Index.astro 0;dev 服务 http://localhost:4321 已起(HMR 生效)。
- 待定:若用户仍嫌大,可进一步把 `.ss-card` 比例从 3/4 改为 4/5 或 1/1 直接缩高度。
## Runway 头部下拉:方案 A 落地(16:26)
- 用户拍板:header 的 Runway 下拉按「真实 collection_type」分 5 类(方案 A),不做婚纱等无数据类。
- 关键发现:Layout.astro 早就有 `runways` 下拉数组,但里面 8 项全是占位垃圾(Spring/Summer 2026…Menswear FW25),且全指向死路由 `ROUTES.shows`(`/shows`);下拉父链接 `<a>` 也错链到 `/shows`。
- 改动:
- `src/i18n/dictionary.ts`:新增 5 个分类 cn 翻译(`ready-to-wear`→女装成衣、`menswear`→男装、`couture`→高级定制、`resort`→度假系列、`pre-fall`→早秋系列)。标签用 `t('Ready-to-Wear')` 等英文 key(英文环境回退显示英文,与列表页 COLLECTION 筛选项 label 一致)。
- `Layout.astro`:`runways` 数组换成 全部 + 5 类,href 用 `href(locale, ROUTES.runwayLooks) + '?collection=xxx'`;父链接 `ROUTES.shows`→`ROUTES.runwayLooks`(修死路由)。
- `RunwayLooks.astro`:`init()` 读 `URLSearchParams(window.location.search).get('collection')`,命中白名单(rtw/menswear/womenswear/couture/resort/pre_fall/bridal/swimwear)则赋值 `selectedCt`,从而深链自动选中筛选(复用现有 loadPage→getArticles({collection}) 链路)。
- 关键事实:`getArticles`(api.ts) 把 `collection` 映射成后端查询参数 `?collection=`(不是 `collection_type`),故深链参数名用 `collection`。
- 验证:`npm run build` BUILD_EXIT=0;read_lints Layout/RunwayLooks 均 0。
- 待用户预览确认;深链真实筛选需后端(:8090)在跑。
## 街拍按地区分类(全链路,18:30 收尾)
- 用户拍板「全链路做」:SQL 加 city 列 + 从 title/URL 回填 + 后端 city 筛选 + 前端 header 下拉 + 列表筛选。
- 后端:model 加 `City` 字段;dto `StreetSnapQuery.City` + `Normalize`;repo `List` 加 `WHERE city = ?`(精确、大小写敏感);handler `parseListQuery` 解析 `c.DefaultQuery("city","")`;SQL `scripts/sql/004_add_streetsnap_city.sql` + 幂等回填 `scripts/backfill_city/main.go`(首选 title 第一段 location 关键词匹配,兜底 URL slug)。
- 前端:`Layout.astro` streets 下拉 18 城(全部 + Paris/New York/Milan/London/Copenhagen/Florence/Berlin/Tokyo/Seoul/Shanghai/Beijing/Rome/Madrid/Amsterdam/Antwerp/Los Angeles/Moscow/Mumbai),href 带 `?city=规范大写名`;`StreetSnaps.astro` 侧栏 City pills + `init()` 读 `?city` 深链自动选中;`api.ts.getStreetSnaps` 透传 `city`;`dictionary.ts` 有各城 cn 翻译(`t('Paris')` 经 `translate` 自动 `toLowerCase` 查到「巴黎」)。
- 关键约定:**city 规范值首字母大写英文**(Paris / New York / Copenhagen…),前后端必须一致且大小写敏感。
- DB+验证:dev 库 db_dev.street_snap 仅 12 行(Paris 8 / Copenhagen 4),其余城本地预览为空属预期(生产真实爬取数据才有完整覆盖)。重启后端 :8090 后 Node 直连验证:`?city=Paris`→8、`?city=Copenhagen`→4、`?city=London`→0、无参→12,全链路通过(client_sign 本地 enabled:false,无需签名)。
- 复盘:标题实际格式为「Street Style 2027 Day 12」而非 `location\tseason\tevent`,city 由旧回填按 URL 预置;生产真实数据靠 title/URL 解析归类。dev 环境 `go run` 可正常编译(仅 `go build ./...` 全量链接会 OOM,非代码问题)。
## Header 精简:移除死链 + street style 减为 3 下拉(18:57)
- 用户要求:去掉 Latest Projects / About / Contact Us;street style 下拉只留三个。
- 现状确认:`Layout.astro` 主导航早已无此三项(仅 Portfolio / Runway(下拉) / Street Style(下拉) / 语言 / Login / Signup);`streets` 下拉当前已是 3 项(`全部 / Paris / New York`)。
- 清理:`routes.ts` 删 `latestProjects`/`about`/`contact` 键;`dictionary.ts` 删 about/contact/latest projects 翻译键;全 src 搜索确认无 `latestProjects|about|contact` 残留引用。
- 验证:`npm run build` exit 0(日志仅 npm warn 包装的 NativeCommandError,非构建错误)。
## Header 下拉数量微调(19:00)
- 用户:street style 下拉再加一个、runway 下拉去掉一个。
- Street:加 **Milan** → `全部 / Paris / New York / Milan` = 4 项(时尚三都补齐;dictionary 已有 `milan→米兰`)。
- Runway:去 **Pre-Fall**(早秋系列,过渡线、数据最少 2376 行),保留 `全部 / rtw / menswear / couture / resort` = 5 项。
- 两个下拉均保留"全部"重置项(结构对称);Runway 父链接仍指向无参列表。
- 验证:待 build。

View File

@ -3,7 +3,9 @@
## 模块策略(重要) ## 模块策略(重要)
- 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。 - 站点按模块逐个完善。当前上线「发布会模块」:首页 index + 走秀列表/详情(RunwayLooks = 走秀档案页,articles/shows 页面已在 i18n 重构中重组)。
- **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。 - **品牌索引页已下线**(2026-08-26 确认):`src/pages/brands.astro` 及孤儿组件 `src/components/pages/brands.astro` 均已删除;品牌数据现在只通过走秀页侧栏 filter + 品牌弹窗呈现(热门 30 条走 SSG 接口,搜索/字母走运行时 `/api/v1/public/brands`)。
- **占位模块组件已清理(2026-08-29)**:`components/pages/` 下 `About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article` 等占位/旧详情组件查无引用、也不是真实路由,已删除并移除空目录。**这些模块的导航链接(`/about` `/contact` `/portfolio` `/latest-projects` `/login` `/register`)仍是死链**——从未有对应页面,待用户决定是否移除导航项。**导航(header)本身保持原样未改动**(用户 2026-08-07 明确「header 别动」)。`/en/login`、`/en/register` 实际并非真实页面(旧 MEMORY 记录有误),AuthForm 组件已随清理删除。 - **占位模块组件已清理(2026-08-29)**:`components/pages/` 下 `About`/`Contact`/`LatestProjects`/`Portfolio`/`Login`/`Register`/`AuthForm`/`article` 等占位/旧详情组件查无引用、也不是真实路由,已删除并移除空目录。
- **导航死链清理(2026-08-30)**:用户决定移除 `/latest-projects`、`/about`、`/contact` 三个死链——已从 `Layout.astro` 主导航(早已不在)、`routes.ts`(`latestProjects`/`about`/`contact` 键)、`dictionary.ts`(about/contact/latest projects 翻译键)三处清除,全 src 扫描零残留引用。**剩余死链 `/portfolio`、`/login`、`/register` 保留未动**(Portfolio 导航项、Login/Signup 链接仍展示,用户未要求移除)。
- **「header 别动」解读(2026-08-30 明确)**:用户「header 别动」指不要擅自改导航结构与视觉;但用户**主动批准**的导航调整(Runway 下拉改 5 个 collection 分类、Street Style 下拉减为 3 城、删除死链项)均属于已授权变更,不算违规。`/en/login`、`/en/register` 实际并非真实页面(旧 MEMORY 记录有误),AuthForm 组件已随清理删除。
## 架构总览 ## 架构总览
- 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。 - 前端:**d:/project/frontend_v2** — Astro SSG + Tailwind v4 + Alpine.js。`npm run build`(SSG 取数走 ssgRequest,失败回落空/假数据不影响构建)。
@ -17,7 +19,7 @@
- **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。 - **品牌名前端一律只显示英文**(用户偏好 2026-08-26:「前端品牌不要展示中文名称,标题可以留着」):api.ts 映射 `display = nameEn`;走秀标题/描述仍按 locale 选列。
- **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。 - **API 命名哲学(用户原则)**:「每一个地方尽可能不要通过参数来复用接口,尽量一个功能一个接口」。查询参数仅限「同一资源的列表筛选/分页」;跨资源/独立功能一律独立路径(如 `brands/hot` 而非 `?featured=1`)。待办:公开侧 `/api/v1/public/brands` 的 featured/keyword/letter 参数拆分、`/articles` 的 `brand_id` 拆 `brands/:id/shows`、资源命名 `shows` vs `articles`、`popular` vs `featured`(目标清单见 artifact `api_endpoints_catalog.md`)。
- CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。 - CSS:能 @apply 就 @apply;`<style>` 顶部 `@reference "tailwindcss";`。Tailwind v4 无零值类。
- 导航(Layout.astro)保持原样未改动(用户「header 别动」),但其部分链接(/about /contact /portfolio /latest-projects /login /register)指向不存在的路由,属待清理死链。 - 导航(Layout.astro):「header 别动」指不擅自改结构/视觉,但用户**主动批准**的导航调整有效(Runway 下拉改 4 个 collection 分类 rtw/menswear/couture/resort、Street Style 下拉减为「全部/Paris/New York/Milan」4 项、移除 latest-projects/about/contact 死链)。剩余死链 /portfolio、/login、/register 用户未要求动,保留。
## Layout 陷阱(2026-08-12) ## Layout 陷阱(2026-08-12)
- Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。 - Layout 把 `<slot />` 包在 `flex-1 flex flex-col` 容器里;页内根容器**禁用 `m-auto`**(cross-axis margin:auto 触发 shrink-to-fit → 内容多少决定宽度、页面抖动)。用 `w-full`(可配 `max-w-[xxx]` + `mx-auto`)。
@ -30,9 +32,16 @@
## 走秀数据分类(brand_runway) ## 走秀数据分类(brand_runway)
- 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。 - 三维分类字段:`collection_type`(rtw/menswear/couture/resort/pre_fall)、`season`(spring/fall/null)、`season_code`(SS26/FW25…派生)。靠 title 规则回填(幂等,25,206 行)。
- 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。 - 公开列表 `?collection_type=&season=&season_code=&year=` 筛选;前端走秀页有系列/季节/年份 chips。
- 地理/城市维度:用户明确暂不做。 - 地理/城市维度(runway):用户明确暂不做。
- 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。 - 分布:rtw 15203 / menswear 4061 / couture 672 / resort 2894 / pre_fall 2376。
## 街拍地区分类(street_snap.city,2026-08-30)
- 全链路已落地:DB 加 `city` 列(VARCHAR(128),脚本 `scripts/sql/004_add_streetsnap_city.sql` + 幂等回填 `scripts/backfill_city/main.go`,无 AutoMigrate)+ 后端 `StreetSnapQuery.City` 精确匹配 `WHERE city = ?` + 前端 header 下拉 `?city=` + 列表侧栏 City pills + 深链自动选中。
- **规范值约定(重要)**:city 列存「首字母大写英文规范名」(Paris / New York / Copenhagen …),前端 header 下拉与列表 `cityOptions` 的 value 用**同一大写值**,后端做精确匹配、大小写敏感,必须前后一致。backfill 首选从 title 第一段(location,`\t` 分隔)按城市关键词匹配,兜底从 source_url/cover 的 URL slug 解析。
- 前端 `t('Paris')` 经 `translate()` 自动 `key.toLowerCase()` 查字典,中文回落「巴黎」,i18n 正常。
- dev 库 db_dev.street_snap 仅 12 行样本(Paris 8 / Copenhagen 4),其余下拉城市(London/Milan…)本地预览为空属预期;生产真实爬取数据才有完整覆盖。
- 验证:重启后端 :8090 后,`?city=Paris`→8、`?city=Copenhagen`→4、`?city=London`→0、无参→12,全链路通过。client_sign 本地 `enabled:false`,可直接 curl/Node 验证。
## 前端组件复用(2026-08-29) ## 前端组件复用(2026-08-29)
- **详情页统一 `/item`**:走秀 lookbook 与街拍详情全部收口到 `src/pages/{locale}/item/[id].astro`,服务端按 `getSsrArticle(id)` → `getSsrStreetSnap(id)` 回落渲染(先 runway 后 street)。`ROUTES.article` 与 `ROUTES.streetSnap` 均指向 `/item/${id}`(`runway-looks/[id]`、`street-snaps/[id]`、`article.astro(?id=)` 等旧详情页均已删除)。列表页回链仍指向列表 `/runway-looks`、`/street-snaps`。 - **详情页统一 `/item`**:走秀 lookbook 与街拍详情全部收口到 `src/pages/{locale}/item/[id].astro`,服务端按 `getSsrArticle(id)` → `getSsrStreetSnap(id)` 回落渲染(先 runway 后 street)。`ROUTES.article` 与 `ROUTES.streetSnap` 均指向 `/item/${id}`(`runway-looks/[id]`、`street-snaps/[id]`、`article.astro(?id=)` 等旧详情页均已删除)。列表页回链仍指向列表 `/runway-looks`、`/street-snaps`。
- **列表页共享资源**:两个列表组件(RunwayLooks / StreetSnaps)同构,抽成 `src/styles/look-grid.css`(卡片/网格/分页/侧栏/spinner/骨架/动画)+ `src/lib/looks-grid.ts`(`SPIN_SVG`、`makeCardSlots(images,count,opts)`、`buildPageList(total,cur)`);品牌筛选弹窗 `src/components/BrandModal.astro`(仅 RunwayLooks 用,`openBrandModal/confirmBrandModal` 等由页面脚本驱动)。卡片标记因在 Alpine `x-for` 内客户端渲染,**不能**提成 Astro 组件。 - **列表页共享资源**:两个列表组件(RunwayLooks / StreetSnaps)同构,抽成 `src/styles/look-grid.css`(卡片/网格/分页/侧栏/spinner/骨架/动画)+ `src/lib/looks-grid.ts`(`SPIN_SVG`、`makeCardSlots(images,count,opts)`、`buildPageList(total,cur)`);品牌筛选弹窗 `src/components/BrandModal.astro`(仅 RunwayLooks 用,`openBrandModal/confirmBrandModal` 等由页面脚本驱动)。卡片标记因在 Alpine `x-for` 内客户端渲染,**不能**提成 Astro 组件。

View File

@ -9,30 +9,30 @@
> test@0.0.1 build > test@0.0.1 build
> astro build > astro build
18:59:58 [@astrojs/node] Enabling sessions with filesystem storage 19:01:18 [@astrojs/node] Enabling sessions with filesystem storage
18:59:59 [types] Generated 233ms 19:01:18 [types] Generated 92ms
18:59:59 [build] output: "static" 19:01:18 [build] output: "static"
18:59:59 [build] mode: "server" 19:01:18 [build] mode: "server"
18:59:59 [build] directory: D:\project\frontend_v2\dist\ 19:01:18 [build] directory: D:\project\frontend_v2\dist\
18:59:59 [build] adapter: @astrojs/node 19:01:18 [build] adapter: @astrojs/node
18:59:59 [build] Collecting build info... 19:01:18 [build] Collecting build info...
18:59:59 [build] ✓ Completed in 435ms. 19:01:18 [build] ✓ Completed in 191ms.
18:59:59 [build] Building server entrypoints... 19:01:18 [build] Building server entrypoints...
19:00:01 [vite] ✓ built in 2.33s 19:01:19 [vite] ✓ built in 802ms
19:00:03 [vite] ✓ built in 1.51s 19:01:19 [vite] ✓ built in 392ms
19:00:03 [vite] ✓ built in 814ms 19:01:20 [vite] ✓ built in 282ms
 prerendering static routes   prerendering static routes 
19:00:04 ├─ /cn/runway-looks/index.html (+110ms) 19:01:20 ├─ /cn/runway-looks/index.html (+48ms)
19:00:04 ├─ /cn/street-snaps/index.html (+19ms) 19:01:20 ├─ /cn/street-snaps/index.html (+9ms)
19:00:04 ├─ /cn/index.html (+45ms) 19:01:20 ├─ /cn/index.html (+35ms)
19:00:04 ├─ /en/runway-looks/index.html (+31ms) 19:01:20 ├─ /en/runway-looks/index.html (+15ms)
19:00:04 ├─ /en/street-snaps/index.html (+16ms) 19:01:20 ├─ /en/street-snaps/index.html (+7ms)
19:00:04 ├─ /en/index.html (+30ms) 19:01:20 ├─ /en/index.html (+24ms)
19:00:04 ├─ /index.html (+37ms) 19:01:20 ├─ /index.html (+20ms)
19:00:04 ✓ Completed in 349ms. 19:01:20 ✓ Completed in 183ms.
 
19:00:04 [build] Rearranging server assets... 19:01:20 [build] Rearranging server assets...
19:00:04 [build] ✓ Completed in 5.26s. 19:01:20 [build] ✓ Completed in 1.78s.
19:00:04 [build] Server built in 5.76s 19:01:20 [build] Server built in 1.98s
19:00:04 [build] Complete! 19:01:20 [build] Complete!

View File

@ -155,28 +155,10 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
{ {
streetItems.length > 0 ? ( streetItems.length > 0 ? (
<section class="ss-section"> <section class="ss-section">
<!-- 顶部 banner:第 1 条最热街拍 --> <!-- 街拍大图卡片网格 -->
{
streetItems[0] && (
<a class="ss-banner" href={href(locale, ROUTES.streetSnap(streetItems[0].id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
<span class="ss-spin" aria-hidden="true" x-show="!loaded && !error">
<svg viewBox="0 0 24 24" width="22" height="22"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="40 22" class="ss-ring" /></svg>
</span>
<img src={streetItems[0].src} alt={streetItems[0].loc} loading="eager" decoding="async" :class="{ 'is-error': error }" @load="loaded = true; $dispatch('img-ready')" @error="error = true; $dispatch('img-ready')" />
<span class="ss-cap">
<span class="ss-cap-loc">{streetItems[0].loc}</span>
<span class="ss-cap-event">{streetItems[0].event}</span>
</span>
</div>
</a>
)
}
<!-- 2 列大图卡片网格:其余街拍 -->
<div class="ss-grid"> <div class="ss-grid">
{ {
streetItems.slice(1).map((g) => ( streetItems.map((g) => (
<a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}> <a class="ss-card" href={href(locale, ROUTES.streetSnap(g.id))}>
<div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }"> <div class="ss-img-box" x-data="{ loaded: false, error: false, init() { const i = this.$el.querySelector('img'); if (i && i.complete) { this.loaded = i.naturalWidth > 0; this.error = i.naturalWidth === 0; } } }" :class="{ 'is-loaded': loaded || error }">
<div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div> <div class="ss-skel" aria-hidden="true" x-show="!loaded && !error"></div>
@ -372,12 +354,8 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
/* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */ /* ===== 首页 Street Style 网格(去掉 swiper,改为 HBX 大图卡片,与街拍列表页一致) ===== */
.ss-section { @apply mb-9; } .ss-section { @apply mb-9; }
.ss-banner {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit mb-7;
aspect-ratio: 16 / 9;
}
.ss-grid { .ss-grid {
@apply grid grid-cols-1 sm:grid-cols-2 gap-[14px] md:gap-[18px]; @apply grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-[14px] md:gap-[18px];
} }
.ss-card { .ss-card {
@apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit; @apply relative block w-full overflow-hidden bg-[#f0f0f0] no-underline text-inherit;
@ -394,8 +372,7 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
} }
.ss-img-box.is-loaded > img { opacity: 1; } .ss-img-box.is-loaded > img { opacity: 1; }
.ss-img-box > img.is-error { opacity: 0 !important; } .ss-img-box > img.is-error { opacity: 0 !important; }
.ss-card:hover .ss-img-box > img, .ss-card:hover .ss-img-box > img { transform: scale(1.04); }
.ss-banner:hover .ss-img-box > img { transform: scale(1.04); }
/* 骨架屏 + spinner(与首页 hs-* 平行) */ /* 骨架屏 + spinner(与首页 hs-* 平行) */
.ss-skel { .ss-skel {
@ -425,22 +402,15 @@ const streetItems: { src: string; loc: string; event: string; id: string }[] = s
gap: 5px; z-index: 3; pointer-events: none; gap: 5px; z-index: 3; pointer-events: none;
} }
.ss-cap-loc { .ss-cap-loc {
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(18px, 2vw, 26px); font-size: clamp(18px, 2vw, 26px);
font-weight: 700; line-height: 1.1; letter-spacing: -0.01em; font-weight: 700; line-height: 1.1; letter-spacing: -0.01em;
text-shadow: 0 1px 4px rgba(0,0,0,0.55); text-shadow: 0 1px 4px rgba(0,0,0,0.55);
} }
.ss-cap-event { .ss-cap-event {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: clamp(10px, 0.8vw, 12px); font-size: clamp(10px, 0.8vw, 12px);
font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4; font-weight: 500; text-transform: uppercase; letter-spacing: 0.16em; line-height: 1.4;
opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5); opacity: 0.85; text-shadow: 0 1px 3px rgba(0,0,0,0.5);
} }
.ss-banner .ss-cap { padding: 44px 28px 26px; }
.ss-banner .ss-cap-loc { font-size: clamp(26px, 4vw, 48px); }
@media (min-width: 768px) {
.ss-banner .ss-cap { padding: 56px 36px 30px; }
}
</style> </style>
<script> <script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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